Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 1ed19c3b002948273e6f87e4cc61e422c758aeb9


Parents : 0125c09
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T20:35:11-05:00

refactor(components): update UI consistency and responsiveness across various components, including App, NotificationBell, and MessagesSidebar, by adjusting class names and layout properties

Changes

27 files changed, 2107 insertions(+), 1118 deletions(-)


Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 4d4bb68b..a1587457 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -17,13 +17,13 @@
<RouterView v-if="$route.name === 'auth'" />
<template v-else>
- <div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
+ <div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<RouterView class="flex-1" />
</div>
<template v-else>
<div
- class="sticky top-0 z-[100] flex bg-white/80 dark:bg-zinc-900/80 backdrop-blur-lg border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors overflow-x-hidden"
+ class="sticky top-0 z-[100] flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors overflow-x-hidden"
>
<div class="flex w-full px-2 sm:px-4 overflow-x-auto no-scrollbar">
<button
@@ -34,10 +34,10 @@
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
- class="hidden sm:flex cursor-pointer my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner"
+ class="my-auto mr-2 hidden w-14 shrink-0 cursor-pointer overflow-hidden rounded-xl sm:flex"
@click="onAppNameClick"
>
- <img class="w-12 h-12 object-contain p-1.5" :src="logoUrl" />
+ <img class="h-14 w-14 object-contain p-1" :src="logoUrl" alt="" />
</div>
<div class="my-auto">
<div
@@ -92,7 +92,7 @@
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="composeNewMessage">
<span
- class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition"
+ class="flex rounded-full border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-white shadow-sm transition hover:bg-zinc-800 dark:border-zinc-400 dark:bg-zinc-200 dark:text-zinc-900 dark:hover:bg-white"
>
<span>
<MaterialDesignIcon icon-name="email" class="w-6 h-6" />
@@ -109,7 +109,7 @@
<!-- middle -->
<div
ref="middle"
- class="flex flex-1 w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors"
+ class="flex flex-1 w-full overflow-hidden bg-slate-50 dark:bg-zinc-950 transition-colors"
>
<!-- sidebar backdrop for mobile -->
<div
@@ -123,14 +123,16 @@
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
- isSidebarCollapsed ? 'w-16' : 'w-80',
+ isSidebarCollapsed ? 'w-16' : 'w-80 md:max-lg:w-64 lg:w-80',
]"
>
<div
- class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur pt-16 sm:pt-0"
+ class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 pt-16 sm:pt-0"
>
- <!-- toggle button for desktop -->
- <div class="hidden sm:flex justify-end p-2 border-b border-gray-100 dark:border-zinc-800">
+ <!-- toggle button for desktop (h-12 aligns with Messages/Nomad collapse rows) -->
+ <div
+ class="hidden sm:flex h-12 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
+ >
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
@@ -316,7 +318,7 @@
<!-- my identity -->
<div
v-if="config"
- class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur"
+ class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950"
>
<div
class="flex text-gray-700 p-3 cursor-pointer"
@@ -393,7 +395,7 @@
<!-- auto announce -->
<div
v-if="config"
- class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800"
+ class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950"
>
<div
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
@@ -1145,9 +1147,12 @@ export default {
}
},
async saveIdentitySettings() {
- await this.updateConfig({
- display_name: this.displayName,
- });
+ await this.updateConfig(
+ {
+ display_name: this.displayName,
+ },
+ "display_name_placeholder"
+ );
},
async onAnnounceIntervalSecondsChange() {
await this.updateConfig(

diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 63f56fe8..2d8266a1 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -18,7 +18,7 @@
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
- class="fixed w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
+ class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
:style="dropdownStyle"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 722179cd..f392f2d9 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -1,32 +1,42 @@
<template>
- <div
- class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
- >
- <div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100">
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <div
+ class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
+ >
<div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24">
<div v-if="appInfo" class="about-section">
- <div class="flex flex-col gap-8 md:flex-row md:items-center">
+ <div class="flex flex-col gap-8 lg:flex-row lg:items-center">
<!-- Logo & Title -->
<div class="flex items-center gap-6">
- <div
- class="w-20 h-20 rounded-3xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-xl"
- >
- <img src="../../public/favicons/favicon-512x512.png" class="w-12 h-12 object-contain" />
- </div>
+ <img
+ src="../../public/favicons/favicon-512x512.png"
+ class="h-20 w-20 shrink-0 object-contain"
+ alt=""
+ />
<div class="space-y-1">
<div
class="text-4xl font-black text-gray-900 dark:text-white leading-none tracking-tight"
>
MeshChatX
</div>
- <div class="text-sm font-black text-blue-500 uppercase tracking-[0.2em] opacity-80">
- {{ $t("about.version", { version: appInfo.version }) }}
+ <div
+ class="flex flex-col gap-0.5 sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3 sm:gap-y-0"
+ >
+ <div class="text-sm font-black uppercase tracking-[0.2em] text-blue-500 opacity-80">
+ {{ $t("about.version", { version: appInfo.version }) }}
+ </div>
+ <div
+ v-if="formattedUiBuildDate"
+ class="text-xs font-medium normal-case tracking-normal text-gray-500 dark:text-zinc-500"
+ >
+ {{ $t("about.ui_build", { date: formattedUiBuildDate }) }}
+ </div>
</div>
</div>
</div>
<div
- class="flex-1 flex flex-wrap justify-stretch sm:justify-end gap-2 sm:gap-3 w-full md:w-auto"
+ class="flex w-full flex-1 flex-wrap justify-stretch gap-2 sm:justify-end sm:gap-3 md:w-auto"
>
<button
type="button"
@@ -179,7 +189,7 @@
<div class="space-y-6">
<!-- Security & Integrity -->
<div v-if="appInfo" class="about-section">
- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between mb-6">
<div
class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
>
@@ -522,7 +532,7 @@
<!-- Database Health -->
<div class="about-section">
- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between mb-8">
<div
class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
>
@@ -559,7 +569,7 @@
</div>
</div>
- <div v-if="databaseHealth" class="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-6 md:gap-8 mb-8">
+ <div v-if="databaseHealth" class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-6 lg:gap-8 mb-8">
<div
class="py-3 px-2 sm:p-4 border-b border-gray-200/60 dark:border-zinc-800/60 md:border md:rounded-xl"
>
@@ -615,7 +625,7 @@
<div class="border-t border-zinc-100 dark:border-zinc-800 pt-8 space-y-8">
<!-- Backups -->
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="space-y-1">
<div
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
@@ -641,7 +651,7 @@
<!-- Snapshots -->
<div class="space-y-6">
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="space-y-1">
<div
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
@@ -749,7 +759,7 @@
<!-- Auto Backups -->
<div v-if="autoBackups && autoBackups.length > 0" class="space-y-6">
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="space-y-1">
<div
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
@@ -898,6 +908,25 @@ export default {
isElectron() {
return ElectronUtils.isElectron();
},
+ formattedUiBuildDate() {
+ try {
+ const raw = typeof __APP_BUILD_TIME__ !== "undefined" ? __APP_BUILD_TIME__ : "";
+ if (!raw) {
+ return "";
+ }
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) {
+ return raw;
+ }
+ return d.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return "";
+ }
+ },
},
mounted() {
this.getAppInfo();

diff --git a/meshchatx/src/frontend/components/archives/ArchiveSidebar.vue b/meshchatx/src/frontend/components/archives/ArchiveSidebar.vue
index 3885bcfa..e1df31f7 100644
--- a/meshchatx/src/frontend/components/archives/ArchiveSidebar.vue
+++ b/meshchatx/src/frontend/components/archives/ArchiveSidebar.vue
@@ -1,9 +1,9 @@
<template>
<div
- class="flex flex-col h-full bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-zinc-800 shrink-0 overflow-hidden"
+ class="flex h-full shrink-0 flex-col overflow-hidden border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950"
>
<!-- Sidebar Header -->
- <div class="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <div class="flex items-center justify-between border-b border-gray-200 p-4 dark:border-zinc-800">
<h2
class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest flex items-center gap-2"
>
@@ -16,7 +16,7 @@
</div>
<!-- Search Bar -->
- <div class="p-2 border-b border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/50">
+ <div class="border-b border-gray-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-950">
<div class="relative">
<MaterialDesignIcon
icon-name="magnify"

diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index ebe47450..c8aff9b1 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -4,7 +4,7 @@
<!-- Sidebar 1: Nodes -->
<ArchiveSidebar
v-if="!isSidebar1Hidden"
- class="w-full sm:w-64 border-r border-gray-200 dark:border-zinc-800 shrink-0"
+ class="w-full sm:w-64 md:max-lg:w-56 lg:w-64 shrink-0 border-r border-gray-200 dark:border-zinc-800"
:class="{ 'hidden sm:flex': selectedNodeHash }"
:nodes="groupedArchives"
:selected-node-hash="selectedNodeHash"
@@ -16,11 +16,11 @@
<!-- Sidebar 2: Snapshots -->
<div
v-if="selectedNode && !isSidebar2Hidden"
- class="w-full sm:w-80 border-r border-gray-200 dark:border-zinc-800 flex flex-col shrink-0 bg-gray-50 dark:bg-zinc-900/50"
+ class="flex w-full shrink-0 flex-col border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 sm:w-80 md:max-lg:w-64 md:max-lg:min-w-64 lg:w-80 lg:min-w-80"
:class="{ 'hidden sm:flex': viewingArchive }"
>
<div
- class="p-3 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col gap-3"
+ class="flex flex-col gap-3 border-b border-gray-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center gap-2">
<button class="sm:hidden p-1 -ml-1 text-gray-500" @click="selectedNodeHash = null">
@@ -134,58 +134,68 @@
<!-- Content Header -->
<div
v-if="viewingArchive"
- class="flex items-center gap-2 p-2 border-b border-zinc-800 bg-zinc-900 text-zinc-300 shrink-0"
+ class="flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white p-2 text-gray-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100"
>
- <button class="sm:hidden p-1 text-zinc-500" @click="viewingArchive = null">
+ <button class="p-1 text-gray-500 sm:hidden dark:text-zinc-500" @click="viewingArchive = null">
<MaterialDesignIcon icon-name="arrow-left" class="size-6" />
</button>
- <div class="flex-1 min-w-0 px-2">
- <div class="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-0.5">
+ <div class="min-w-0 flex-1 px-2">
+ <div
+ class="mb-0.5 text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-zinc-500"
+ >
Viewing Archive
</div>
- <div class="text-sm font-mono truncate text-white">{{ viewingArchive.page_path || "/" }}</div>
+ <div class="truncate font-mono text-sm text-gray-900 dark:text-white">
+ {{ viewingArchive.page_path || "/" }}
+ </div>
</div>
<div class="flex items-center gap-1">
<button
- class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
- :class="{ 'text-blue-400': !isSidebar1Hidden, 'text-zinc-600': isSidebar1Hidden }"
+ class="hidden rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-zinc-800 sm:block"
+ :class="{
+ 'text-blue-600 dark:text-blue-400': !isSidebar1Hidden,
+ 'text-gray-400 dark:text-zinc-500': isSidebar1Hidden,
+ }"
:title="isSidebar1Hidden ? 'Show Nodes' : 'Hide Nodes'"
@click="isSidebar1Hidden = !isSidebar1Hidden"
>
<MaterialDesignIcon icon-name="page-layout-sidebar-left" class="size-4" />
</button>
<button
- class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
- :class="{ 'text-blue-400': !isSidebar2Hidden, 'text-zinc-600': isSidebar2Hidden }"
+ class="hidden rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-zinc-800 sm:block"
+ :class="{
+ 'text-blue-600 dark:text-blue-400': !isSidebar2Hidden,
+ 'text-gray-400 dark:text-zinc-500': isSidebar2Hidden,
+ }"
:title="isSidebar2Hidden ? 'Show Snapshots' : 'Hide Snapshots'"
@click="isSidebar2Hidden = !isSidebar2Hidden"
>
<MaterialDesignIcon icon-name="view-list" class="size-4" />
</button>
- <div class="hidden sm:block w-px h-6 bg-zinc-800 mx-1"></div>
+ <div class="mx-1 hidden h-6 w-px bg-gray-200 dark:bg-zinc-800 sm:block"></div>
<button
- class="p-2 hover:bg-zinc-800 rounded transition-colors text-zinc-300 flex items-center gap-2"
+ class="flex items-center gap-2 rounded p-2 text-gray-700 transition-colors hover:bg-gray-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
:title="$t('archives.export_mu')"
@click="exportArchiveAsMu(viewingArchive)"
>
<MaterialDesignIcon icon-name="download" class="size-4" />
- <span class="hidden xs:inline text-xs font-bold uppercase tracking-wider">{{
+ <span class="hidden text-xs font-bold uppercase tracking-wider xs:inline">{{
$t("archives.export_mu")
}}</span>
</button>
- <div class="hidden xs:block w-px h-6 bg-zinc-800 mx-1"></div>
+ <div class="mx-1 hidden h-6 w-px bg-gray-200 dark:bg-zinc-800 xs:block"></div>
<button
- class="p-2 hover:bg-zinc-800 rounded transition-colors text-blue-400 flex items-center gap-2"
+ class="flex items-center gap-2 rounded p-2 text-blue-600 transition-colors hover:bg-gray-100 dark:text-blue-400 dark:hover:bg-zinc-800"
@click="openInNomadnet(viewingArchive)"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-4" />
- <span class="hidden xs:inline text-xs font-bold uppercase tracking-wider">Open Live</span>
+ <span class="hidden text-xs font-bold uppercase tracking-wider xs:inline">Open Live</span>
</button>
- <div class="hidden xs:block w-px h-6 bg-zinc-800 mx-1"></div>
+ <div class="mx-1 hidden h-6 w-px bg-gray-200 dark:bg-zinc-800 xs:block"></div>
<button
- class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
+ class="hidden rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-zinc-800 sm:block"
title="Close"
@click="viewingArchive = null"
>

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index e6c60652..6b72b8c4 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -1543,7 +1543,7 @@
<div class="space-y-6">
<!-- Enabled Toggle & Volume -->
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex-1">
<div class="flex items-center justify-between mb-1">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
@@ -1602,7 +1602,7 @@
<!-- Tone Generator Settings -->
<div
- class="flex flex-col md:flex-row md:items-center justify-between gap-6 pt-4 border-t border-gray-100 dark:border-zinc-800/50"
+ class="flex flex-col lg:flex-row lg:items-center justify-between gap-6 pt-4 border-t border-gray-100 dark:border-zinc-800/50"
>
<div class="flex-1">
<div class="flex items-center justify-between mb-1">
@@ -1671,7 +1671,7 @@
<div
class="p-4 rounded-xl bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100/50 dark:border-blue-900/30"
>
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("call.default_ringtone") }}

diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index 122ced1c..d6786e0a 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -2,7 +2,7 @@
<div class="flex flex-1 min-w-0 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="max-w-5xl mx-auto space-y-4">
- <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
+ <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100">{{ $t("contacts.title") }}</h1>
<p class="text-sm text-gray-600 dark:text-zinc-400">

diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
index 1c477918..ba592973 100644
--- a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
+++ b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex flex-col h-full overflow-hidden w-full px-4 md:px-8 py-6">
+ <div class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6">
<div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-1">

diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index 7d8c1d97..b9c7c200 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -225,7 +225,7 @@
v-if="(status.has_docs || status.has_meshchatx_docs) && !isSearching"
class="lg:hidden px-3 py-2 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 z-10"
>
- <div class="flex flex-col md:flex-row items-center gap-2 w-full">
+ <div class="flex flex-col lg:flex-row items-center gap-2 w-full">
<!-- Tabs -->
<div class="flex bg-gray-100 dark:bg-zinc-800 p-0.5 rounded-lg w-full md:w-auto">
<button

diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 1b0bef6f..7efc95a6 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div class="overflow-y-auto flex-1 min-h-0">
- <div class="w-full max-w-[1920px] mx-auto px-4 md:px-8 py-4 md:py-8 space-y-6">
+ <div class="w-full max-w-[1920px] mx-auto px-4 md:px-5 lg:px-8 py-4 md:py-6 lg:py-8 space-y-6">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="min-w-0">

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 725684dd..4dd83d15 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-8 py-4 sm:py-6">
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full min-w-0 max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto flex-1">
<div
v-if="showRestartReminder"
@@ -27,7 +27,7 @@
</div>
<div
- class="interfaces-section interfaces-section--hero flex flex-col md:flex-row md:items-center justify-between gap-6"
+ class="interfaces-section interfaces-section--hero flex flex-col lg:flex-row lg:items-center justify-between gap-6"
>
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -467,7 +467,7 @@
Configure Per-Interface
</RouterLink>
</div>
- <div class="grid gap-4 min-w-0 md:grid-cols-2">
+ <div class="grid gap-4 min-w-0 lg:grid-cols-2">
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300 min-w-0">
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
<div>

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 103e9f69..eddaa295 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -170,7 +170,7 @@
<div
v-if="!offlineEnabled"
ref="searchContainer"
- class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-30"
+ class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 md:max-lg:w-72 lg:w-80 z-30"
>
<div class="relative">
<div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
@@ -361,7 +361,7 @@
<!-- telemetry marker overlay -->
<div
v-if="selectedMarker"
- class="absolute bottom-4 left-4 right-4 sm:left-4 sm:right-auto sm:w-80 z-20 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden text-gray-900 dark:text-zinc-100"
+ class="absolute bottom-4 left-4 right-4 sm:left-4 sm:right-auto sm:w-80 md:max-lg:w-72 lg:w-80 z-20 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden text-gray-900 dark:text-zinc-100"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
<div class="flex items-center gap-3">

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 1e02effa..b85e90a2 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -22,7 +22,8 @@
<!-- header -->
<div
- class="relative z-20 flex flex-wrap items-center gap-y-2 px-3 sm:px-4 py-3 border-b border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm"
+ ref="conversationPeerHeader"
+ class="relative z-20 flex flex-wrap items-center gap-y-2 px-3 sm:px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
>
<!-- peer icon -->
<div class="flex-shrink-0 mr-3">
@@ -389,16 +390,12 @@
</div>
<!-- chat items -->
- <div
- id="messages"
- class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50"
- @scroll="onMessagesScroll"
- >
+ <div id="messages" class="h-full overflow-y-scroll bg-white dark:bg-zinc-950" @scroll="onMessagesScroll">
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6 min-w-0">
<template v-for="entry in selectedPeerChatDisplayGroups" :key="entry.key">
<div
v-if="entry.type === 'imageGroup'"
- class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
+ class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-4 group min-w-0"
:class="{
'ml-auto items-end': entry.items[0].is_outbound,
'mr-auto items-start': !entry.items[0].is_outbound,
@@ -526,8 +523,8 @@
? 'shadow-sm'
: entry.items[0].lxmf_message.is_spam
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
- : isOutboundPathfindingBubble(entry.items[0])
- ? 'bg-gray-200 dark:bg-zinc-700 text-gray-900 dark:text-zinc-100 border border-gray-300 dark:border-zinc-600 shadow-sm'
+ : isOutboundWaitingBubble(entry.items[0])
+ ? 'shadow-sm'
: entry.items[0].is_outbound
? outboundBubbleSurfaceClass(entry.items[0])
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm',
@@ -708,7 +705,7 @@
v-else
:id="`message-${chatItem.lxmf_message.hash}`"
:key="chatItem.lxmf_message.hash"
- class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
+ class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-4 group min-w-0"
:class="{
'ml-auto items-end': chatItem.is_outbound,
'mr-auto items-start': !chatItem.is_outbound,
@@ -747,8 +744,8 @@
? 'shadow-sm'
: chatItem.lxmf_message.is_spam
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
- : isOutboundPathfindingBubble(chatItem)
- ? 'bg-gray-200 dark:bg-zinc-700 text-gray-900 dark:text-zinc-100 border border-gray-300 dark:border-zinc-600 shadow-sm'
+ : isOutboundWaitingBubble(chatItem)
+ ? 'shadow-sm'
: chatItem.is_outbound
? outboundBubbleSurfaceClass(chatItem)
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm',
@@ -1363,9 +1360,7 @@
</div>
<!-- send message -->
- <div
- class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5"
- >
+ <div class="w-full border-t border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 sm:px-4 py-2.5">
<div class="w-full">
<!-- banished user notification -->
<div
@@ -1558,20 +1553,158 @@
</div>
<!-- text input + send -->
- <div class="flex items-center gap-2">
- <textarea
- id="message-input"
- ref="message-input"
- v-model="newMessageText"
- :readonly="isTranslatingMessage"
- class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full min-w-0 flex-1 px-3 sm:px-4 py-2.5 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500 min-h-[44px] max-h-[200px] overflow-y-auto leading-snug"
- rows="1"
- spellcheck="true"
- :placeholder="$t('messages.send_placeholder')"
- @keydown.enter.exact.prevent="onEnterPressed"
- @keydown.enter.shift.exact.prevent="onShiftEnterPressed"
- @paste="onMessagePaste"
- ></textarea>
+ <div class="flex items-center gap-2 min-w-0">
+ <div
+ v-click-outside="{ handler: onStickerPickerClickOutside, capture: true }"
+ class="relative flex-1 min-w-0"
+ >
+ <textarea
+ id="message-input"
+ ref="message-input"
+ v-model="newMessageText"
+ :readonly="isTranslatingMessage"
+ class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full min-w-0 pl-3 sm:pl-4 pr-11 py-2.5 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500 min-h-[44px] max-h-[200px] overflow-y-auto leading-snug"
+ rows="1"
+ spellcheck="true"
+ :placeholder="$t('messages.send_placeholder')"
+ @keydown.enter.exact.prevent="onEnterPressed"
+ @keydown.enter.shift.exact.prevent="onShiftEnterPressed"
+ @paste="onMessagePaste"
+ ></textarea>
+ <button
+ type="button"
+ class="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100"
+ :title="$t('stickers.picker_tooltip')"
+ @click.stop="toggleStickerPicker"
+ >
+ <MaterialDesignIcon icon-name="emoticon-outline" class="w-5 h-5" />
+ </button>
+ <div
+ v-if="isStickerPickerOpen"
+ class="absolute bottom-full right-0 mb-2 z-50 w-[min(320px,85vw)] max-h-[min(420px,70vh)] flex flex-col rounded-2xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl overflow-hidden"
+ :class="{
+ 'ring-2 ring-blue-500/50 ring-offset-2 ring-offset-white dark:ring-offset-zinc-900':
+ stickerDropActive && emojiStickerTab === 'stickers',
+ }"
+ @click.stop
+ >
+ <div
+ class="flex shrink-0 border-b border-gray-200 dark:border-zinc-700 p-1 gap-0.5"
+ role="tablist"
+ >
+ <button
+ type="button"
+ role="tab"
+ :aria-selected="emojiStickerTab === 'emoji'"
+ class="flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-colors"
+ :class="
+ emojiStickerTab === 'emoji'
+ ? 'bg-blue-100 dark:bg-blue-950/60 text-blue-800 dark:text-blue-200'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
+ "
+ @click="emojiStickerTab = 'emoji'"
+ >
+ {{ $t("stickers.tab_emojis") }}
+ </button>
+ <button
+ type="button"
+ role="tab"
+ :aria-selected="emojiStickerTab === 'stickers'"
+ class="flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-colors"
+ :class="
+ emojiStickerTab === 'stickers'
+ ? 'bg-blue-100 dark:bg-blue-950/60 text-blue-800 dark:text-blue-200'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
+ "
+ @click="emojiStickerTab = 'stickers'"
+ >
+ {{ $t("stickers.tab_stickers") }}
+ </button>
+ </div>
+ <div
+ v-show="emojiStickerTab === 'emoji'"
+ class="min-h-0 flex-1 flex flex-col overflow-hidden p-0"
+ role="tabpanel"
+ >
+ <emoji-picker
+ :data-source="emojiPickerDataUrl"
+ :class="emojiPickerThemeClass"
+ class="compose-emoji-picker"
+ @emoji-click="onEmojiPickerClick"
+ />
+ </div>
+ <div
+ v-show="emojiStickerTab === 'stickers'"
+ class="min-h-0 flex-1 overflow-y-auto p-2"
+ role="tabpanel"
+ @dragover.prevent="onStickerPanelDragOver"
+ @dragleave.prevent="onStickerPanelDragLeave"
+ @drop.prevent="onStickerPanelDrop"
+ >
+ <input
+ ref="sticker-upload-input"
+ type="file"
+ accept="image/png,image/jpeg,image/gif,image/webp,image/bmp,.png,.jpg,.jpeg,.gif,.webp,.bmp"
+ multiple
+ class="hidden"
+ @change="onStickerUploadInputChange"
+ />
+ <div v-if="userStickers.length > 0" class="grid grid-cols-4 gap-2 mb-2">
+ <button
+ v-for="s in userStickers"
+ :key="s.id"
+ type="button"
+ class="aspect-square rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-700 hover:ring-2 hover:ring-blue-500/50"
+ :title="s.name || 'Sticker'"
+ @click="addStickerFromLibrary(s)"
+ >
+ <img
+ :src="stickerImageUrl(s.id)"
+ class="w-full h-full object-contain bg-gray-50 dark:bg-zinc-800"
+ alt=""
+ />
+ </button>
+ </div>
+ <div
+ v-if="userStickers.length === 0"
+ class="text-center text-sm text-gray-500 dark:text-zinc-400 mb-2 px-1"
+ >
+ {{ $t("stickers.empty_library") }}
+ </div>
+ <button
+ type="button"
+ class="w-full rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-600 px-3 py-3 text-left transition-colors hover:border-blue-400 hover:bg-blue-50/60 dark:hover:bg-blue-950/30"
+ :class="
+ stickerDropActive ? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40' : ''
+ "
+ :disabled="isStickerUploading"
+ @click="triggerStickerUploadInput"
+ >
+ <div class="flex items-start gap-2">
+ <MaterialDesignIcon
+ icon-name="upload"
+ class="size-5 shrink-0 text-blue-500 mt-0.5"
+ />
+ <div class="min-w-0">
+ <div class="text-xs font-medium text-gray-800 dark:text-zinc-100">
+ {{
+ userStickers.length > 0
+ ? $t("stickers.add_more_hint")
+ : $t("stickers.drop_or_click_hint")
+ }}
+ </div>
+ <div
+ v-if="isStickerUploading"
+ class="text-[11px] text-blue-600 dark:text-blue-400 mt-1"
+ >
+ {{ $t("common.loading") }}
+ </div>
+ </div>
+ </div>
+ </button>
+ </div>
+ </div>
+ </div>
<div class="shrink-0 flex items-center">
<SendMessageButton
:is-sending-message="false"
@@ -1719,6 +1852,15 @@
<MaterialDesignIcon icon-name="code-json" class="size-4 text-gray-400" />
<span class="font-medium">View Raw LXM</span>
</button>
+ <button
+ v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="saveMessageImageToStickers(messageContextMenu.chatItem)"
+ >
+ <MaterialDesignIcon icon-name="bookmark-plus-outline" class="size-4 text-teal-500" />
+ <span class="font-medium">{{ $t("stickers.save_to_library") }}</span>
+ </button>
<button
v-if="
messageContextMenu.chatItem?.is_outbound &&
@@ -2074,7 +2216,7 @@
<div class="p-0 overflow-y-auto bg-gray-50 dark:bg-zinc-950 flex-grow">
<div class="p-6 space-y-6">
<!-- header / status info -->
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-1">
<label
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
@@ -2120,7 +2262,7 @@
</div>
</div>
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-1">
<label
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
@@ -2141,7 +2283,7 @@
</div>
</div>
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="space-y-1">
<label
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
@@ -2260,6 +2402,8 @@ import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
import { createOutboundQueue } from "../../js/outboundSendQueue";
+import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
+import "emoji-picker-element";
export default {
name: "ConversationViewer",
@@ -2375,19 +2519,31 @@ export default {
chatItem: null,
justOpened: false,
},
+ userStickers: [],
+ isStickerPickerOpen: false,
+ emojiStickerTab: "emoji",
+ emojiPickerDataUrl: emojiPickerEnDataUrl,
+ stickerDropActive: false,
+ isStickerUploading: false,
now: Date.now(),
updateTimer: null,
sendStatusUiMs: Date.now(),
sendStatusTickInterval: null,
windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
+ peerHeaderCompact: false,
+ peerHeaderResizeObserver: null,
};
},
computed: {
compactPeerActions() {
- return this.windowWidth < 640;
+ return this.windowWidth < 640 || this.peerHeaderCompact;
},
compactSendLayout() {
- return this.windowWidth < 640;
+ return this.windowWidth < 640 || this.peerHeaderCompact;
+ },
+ emojiPickerThemeClass() {
+ void GlobalState.config?.theme;
+ return GlobalState.config?.theme === "dark" ? "dark" : "light";
},
usesThemeOutboundBubbleColor() {
const c = GlobalState?.config?.message_outbound_bubble_color;
@@ -2418,16 +2574,11 @@ export default {
styles["color"] = "#ffffff";
} else if (chatItem.is_outbound) {
if (chatItem.lxmf_message?._pendingPathfinding) {
- if (!this.showRichOutboundPendingUi(chatItem)) {
- if (useThemeOutbound) {
- return {};
- }
- const color = cfg?.message_outbound_bubble_color || "#4f46e5";
- styles["background-color"] = color;
- styles["color"] = "#ffffff";
- return styles;
- }
- return {};
+ const hex = cfg?.message_waiting_bubble_color || "#e5e7eb";
+ styles["background-color"] = hex;
+ styles["color"] = this.pickTextColorForBubbleBackground(hex);
+ styles["border"] = this.waitingBubbleBorderForHex(hex);
+ return styles;
}
if (useThemeOutbound) {
return {};
@@ -2674,12 +2825,17 @@ export default {
if (oldPeer) {
this.saveDraft(oldPeer.destination_hash);
}
+ this.teardownPeerHeaderResizeObserver();
+ if (!newPeer) {
+ this.peerHeaderCompact = false;
+ }
this.checkIfSelectedPeerBlocked();
this.strangerBannerDismissed = false;
this.checkIfStrangerPeer();
this.initialLoad();
if (newPeer) {
this.loadDraft(newPeer.destination_hash);
+ this.$nextTick(() => this.setupPeerHeaderResizeObserver());
}
},
immediate: true,
@@ -2740,6 +2896,7 @@ export default {
}, 2000);
this._scheduleOutboundSendStatusTick();
+ this.loadUserStickers();
this._onWindowResize = () => {
this.windowWidth = window.innerWidth;
@@ -2747,6 +2904,7 @@ export default {
window.addEventListener("resize", this._onWindowResize);
},
beforeUnmount() {
+ this.teardownPeerHeaderResizeObserver();
if (this.selectedPeer) {
this.saveDraft(this.selectedPeer.destination_hash);
}
@@ -2769,6 +2927,29 @@ export default {
}
},
methods: {
+ setupPeerHeaderResizeObserver() {
+ this.teardownPeerHeaderResizeObserver();
+ const el = this.$refs.conversationPeerHeader;
+ if (!el || typeof ResizeObserver === "undefined") {
+ return;
+ }
+ const threshold = 820;
+ const apply = (width) => {
+ this.peerHeaderCompact = width > 0 && width < threshold;
+ };
+ this.peerHeaderResizeObserver = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect?.width ?? 0;
+ apply(w);
+ });
+ this.peerHeaderResizeObserver.observe(el);
+ apply(el.clientWidth);
+ },
+ teardownPeerHeaderResizeObserver() {
+ if (this.peerHeaderResizeObserver) {
+ this.peerHeaderResizeObserver.disconnect();
+ this.peerHeaderResizeObserver = null;
+ }
+ },
renderMarkdown(text) {
return MarkdownRenderer.render(text);
},
@@ -3828,12 +4009,49 @@ export default {
}
return "Pending";
},
- isOutboundPathfindingBubble(chatItem) {
- return Boolean(
- chatItem?.is_outbound &&
- chatItem?.lxmf_message?._pendingPathfinding &&
- this.showRichOutboundPendingUi(chatItem)
- );
+ isOutboundWaitingBubble(chatItem) {
+ return Boolean(chatItem?.is_outbound && chatItem?.lxmf_message?._pendingPathfinding);
+ },
+ _hexToRgb(hex) {
+ const s = String(hex ?? "")
+ .trim()
+ .replace(/^#/, "");
+ if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) {
+ return null;
+ }
+ return {
+ r: parseInt(s.slice(0, 2), 16),
+ g: parseInt(s.slice(2, 4), 16),
+ b: parseInt(s.slice(4, 6), 16),
+ };
+ },
+ _hexRelativeLuminance(hex) {
+ const rgb = this._hexToRgb(hex);
+ if (!rgb) {
+ return null;
+ }
+ const toLinear = (c) => {
+ const x = c / 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+ };
+ const R = toLinear(rgb.r);
+ const G = toLinear(rgb.g);
+ const B = toLinear(rgb.b);
+ return 0.2126 * R + 0.7152 * G + 0.0722 * B;
+ },
+ pickTextColorForBubbleBackground(hex) {
+ const lum = this._hexRelativeLuminance(hex);
+ if (lum == null) {
+ return "#111827";
+ }
+ return lum > 0.45 ? "#111827" : "#ffffff";
+ },
+ waitingBubbleBorderForHex(hex) {
+ const lum = this._hexRelativeLuminance(hex);
+ if (lum == null) {
+ return "1px solid rgba(15, 23, 42, 0.12)";
+ }
+ return lum > 0.45 ? "1px solid rgba(15, 23, 42, 0.12)" : "1px solid rgba(255, 255, 255, 0.14)";
},
isOutboundSendingBusy(chatItem) {
const m = chatItem?.lxmf_message;
@@ -3908,7 +4126,7 @@ export default {
if (chatItem.lxmf_message.is_spam) {
return "";
}
- if (this.isOutboundPathfindingBubble(chatItem)) {
+ if (chatItem.lxmf_message?._pendingPathfinding) {
return "";
}
if (!this.usesThemeOutboundBubbleColor) {
@@ -3920,7 +4138,7 @@ export default {
if (!chatItem.is_outbound) {
return "text-gray-500 dark:text-zinc-400";
}
- if (this.isOutboundPathfindingBubble(chatItem)) {
+ if (this.isOutboundWaitingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
@@ -3929,7 +4147,7 @@ export default {
return "text-white/90";
},
outboundSendingStatusIconClass(chatItem) {
- if (this.isOutboundPathfindingBubble(chatItem)) {
+ if (this.isOutboundWaitingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
@@ -3941,7 +4159,7 @@ export default {
if (!chatItem.is_outbound) {
return "text-indigo-500/80";
}
- if (this.isOutboundPathfindingBubble(chatItem)) {
+ if (this.isOutboundWaitingBubble(chatItem)) {
return "text-gray-700 dark:text-gray-300";
}
if (this.isThemeOutboundBubble(chatItem)) {
@@ -3953,7 +4171,7 @@ export default {
if (!chatItem.is_outbound) {
return "text-gray-500 dark:text-zinc-400";
}
- if (this.isOutboundPathfindingBubble(chatItem)) {
+ if (this.isOutboundWaitingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
@@ -3999,7 +4217,7 @@ export default {
},
outboundExpandedActionsShellClass(chatItem) {
if (!chatItem?.is_outbound) {
- return "border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50";
+ return "border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "border-sky-200/70 dark:border-sky-800/50 bg-sky-50/40 dark:bg-sky-950/35";
@@ -4827,6 +5045,218 @@ export default {
// convert image to data url
fileReader.readAsDataURL(imageBlob);
},
+ onStickerPickerClickOutside() {
+ this.isStickerPickerOpen = false;
+ this.stickerDropActive = false;
+ },
+ toggleStickerPicker() {
+ if (!this.isStickerPickerOpen) {
+ this.loadUserStickers();
+ this.emojiStickerTab = "emoji";
+ }
+ this.isStickerPickerOpen = !this.isStickerPickerOpen;
+ },
+ onEmojiPickerClick(event) {
+ const unicode = event?.detail?.unicode;
+ if (unicode) {
+ this.insertEmojiAtCaret(unicode);
+ }
+ },
+ insertEmojiAtCaret(emoji) {
+ const ta = this.$refs["message-input"];
+ const text = this.newMessageText ?? "";
+ if (!ta || typeof ta.selectionStart !== "number") {
+ this.newMessageText = text + emoji;
+ return;
+ }
+ const start = ta.selectionStart;
+ const end = ta.selectionEnd ?? start;
+ this.newMessageText = text.slice(0, start) + emoji + text.slice(end);
+ this.$nextTick(() => {
+ const el = this.$refs["message-input"];
+ if (!el) {
+ return;
+ }
+ el.focus();
+ const pos = start + emoji.length;
+ el.setSelectionRange(pos, pos);
+ });
+ },
+ async loadUserStickers() {
+ try {
+ const r = await window.api.get("/api/v1/stickers");
+ this.userStickers = r.data?.stickers ?? [];
+ } catch {
+ this.userStickers = [];
+ }
+ },
+ stickerImageUrl(stickerId) {
+ return `/api/v1/stickers/${stickerId}/image`;
+ },
+ onStickerPanelDragOver(event) {
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "copy";
+ }
+ this.stickerDropActive = true;
+ },
+ onStickerPanelDragLeave(event) {
+ const el = event.currentTarget;
+ if (el && event.relatedTarget && el.contains(event.relatedTarget)) {
+ return;
+ }
+ this.stickerDropActive = false;
+ },
+ onStickerPanelDrop(event) {
+ event.preventDefault();
+ this.stickerDropActive = false;
+ const files = event.dataTransfer?.files;
+ if (files?.length) {
+ this.uploadStickerImageFiles(files);
+ }
+ },
+ triggerStickerUploadInput() {
+ const input = this.$refs["sticker-upload-input"];
+ if (input) input.click();
+ },
+ onStickerUploadInputChange(event) {
+ const files = event.target.files;
+ if (files?.length) {
+ this.uploadStickerImageFiles(files);
+ }
+ event.target.value = "";
+ },
+ mimeToStickerType(mime, name = "") {
+ const m = (mime || "").toLowerCase().split(";")[0].trim();
+ const map = {
+ "image/png": "png",
+ "image/jpeg": "jpeg",
+ "image/jpg": "jpeg",
+ "image/gif": "gif",
+ "image/webp": "webp",
+ "image/bmp": "bmp",
+ "image/x-ms-bmp": "bmp",
+ };
+ if (map[m]) {
+ return map[m];
+ }
+ const ext = (name.split(".").pop() || "").toLowerCase();
+ const extMap = { png: "png", jpg: "jpeg", jpeg: "jpeg", gif: "gif", webp: "webp", bmp: "bmp" };
+ return extMap[ext] || null;
+ },
+ async uploadStickerImageFiles(fileList) {
+ const maxBytes = 512 * 1024;
+ const files = Array.from(fileList || []).filter((f) => f && f.size > 0);
+ if (files.length === 0) {
+ return;
+ }
+ this.isStickerUploading = true;
+ let added = 0;
+ let dup = 0;
+ let failed = 0;
+ try {
+ for (const file of files) {
+ if (file.size > maxBytes) {
+ ToastUtils.error(this.$t("stickers.file_too_large"));
+ failed++;
+ continue;
+ }
+ const imageType = this.mimeToStickerType(file.type, file.name);
+ if (!imageType) {
+ ToastUtils.error(this.$t("stickers.unsupported_type"));
+ failed++;
+ continue;
+ }
+ try {
+ const buf = await file.arrayBuffer();
+ const imageBytes = Utils.arrayBufferToBase64(buf);
+ await window.api.post("/api/v1/stickers", {
+ image_bytes: imageBytes,
+ image_type: imageType,
+ name: null,
+ });
+ added++;
+ } catch (e) {
+ const err = e?.response?.data?.error;
+ if (err === "duplicate_sticker") {
+ dup++;
+ } else {
+ failed++;
+ console.error(e);
+ }
+ }
+ }
+ await this.loadUserStickers();
+ if (added > 0) {
+ ToastUtils.success(this.$t("stickers.uploaded_count", { count: added }));
+ }
+ if (dup > 0 && added === 0 && failed === 0) {
+ ToastUtils.info(this.$t("stickers.duplicate"));
+ } else if (dup > 0 && added > 0) {
+ ToastUtils.info(this.$t("stickers.duplicate"));
+ }
+ if (failed > 0 && added === 0 && dup === 0) {
+ ToastUtils.error(this.$t("stickers.save_failed"));
+ }
+ } finally {
+ this.isStickerUploading = false;
+ }
+ },
+ async addStickerFromLibrary(sticker) {
+ try {
+ const res = await window.api.get(`/api/v1/stickers/${sticker.id}/image`, {
+ responseType: "blob",
+ });
+ const blob = res.data;
+ const ext = sticker.image_type === "jpeg" ? "jpg" : sticker.image_type;
+ const mime = blob.type || `image/${sticker.image_type}`;
+ const file = new File([blob], `sticker-${sticker.id}.${ext}`, { type: mime });
+ this.onImageSelected(file);
+ this.isStickerPickerOpen = false;
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("stickers.save_failed"));
+ }
+ },
+ async saveMessageImageToStickers(chatItem) {
+ this.messageContextMenu.show = false;
+ const msg = chatItem.lxmf_message;
+ const img = msg.fields?.image;
+ if (!img) {
+ return;
+ }
+ let b64 = img.image_bytes;
+ if (!b64) {
+ try {
+ const res = await window.api.get(`/api/v1/lxmf-messages/attachment/${msg.hash}/image`, {
+ responseType: "arraybuffer",
+ });
+ b64 = Utils.arrayBufferToBase64(res.data);
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("stickers.save_failed"));
+ return;
+ }
+ }
+ const imageType = String(img.image_type || "png").replace(/^image\//, "");
+ try {
+ await window.api.post("/api/v1/stickers", {
+ image_bytes: b64,
+ image_type: imageType,
+ source_message_hash: msg.hash,
+ name: null,
+ });
+ ToastUtils.success(this.$t("stickers.saved"));
+ await this.loadUserStickers();
+ } catch (e) {
+ const err = e?.response?.data?.error;
+ if (err === "duplicate_sticker") {
+ ToastUtils.info(this.$t("stickers.duplicate"));
+ } else {
+ ToastUtils.error(this.$t("stickers.save_failed"));
+ }
+ }
+ },
async startRecordingAudioAttachment(args) {
// do nothing if already recording
if (this.isRecordingAudioAttachment) {
@@ -5152,6 +5582,13 @@ export default {
@apply bg-zinc-800 text-white border-blue-500 !important;
}
+.compose-emoji-picker {
+ width: 100%;
+ height: min(320px, 50vh);
+ min-height: 220px;
+ --border-radius: 0.75rem;
+}
+
.audio-controls-light {
filter: invert(1) hue-rotate(180deg);
}

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index a0a418dd..e4556777 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -3,6 +3,7 @@
<MessagesSidebar
v-if="!isPopoutMode"
:class="{ 'hidden sm:flex': destinationHash }"
+ :collapsed="messagesListSidebarCollapsed"
:conversations="conversations"
:peers="peers"
:folders="folders"
@@ -38,10 +39,11 @@
@export-folders="onExportFolders"
@import-folders="onImportFolders"
@toggle-conversation-pin="onToggleConversationPin"
+ @toggle-collapse="messagesListSidebarCollapsed = !messagesListSidebarCollapsed"
/>
<div
- class="flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-white via-slate-50 to-slate-100 dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900/80"
+ class="flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950"
:class="destinationHash ? 'flex' : 'hidden sm:flex'"
>
<!-- messages tab -->
@@ -156,6 +158,7 @@ export default {
config: snapshotGlobalConfig(),
hasLoadedConversations: false,
+ messagesListSidebarCollapsed: false,
peers: {},
selectedPeer: null,

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 4283f089..2434dd14 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -1,700 +1,799 @@
<template>
- <div class="flex flex-col w-full sm:w-80 sm:min-w-80">
- <!-- tabs -->
- <div class="bg-transparent border-b border-r border-gray-200/70 dark:border-zinc-700/80 backdrop-blur">
- <div class="-mb-px flex">
- <div
- class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
- :class="[
+ <div :class="sidebarRootClass">
+ <div
+ v-if="effectiveCollapsed"
+ class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
+ >
+ <div
+ class="hidden sm:flex h-12 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
+ >
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
+ @click="$emit('toggle-collapse')"
+ >
+ <MaterialDesignIcon icon-name="chevron-right" class="size-5" />
+ </button>
+ </div>
+ <div class="flex flex-col items-center gap-1 py-2 px-1 border-b border-gray-200 dark:border-zinc-800">
+ <button
+ type="button"
+ class="p-2 rounded-xl transition-colors"
+ :class="
tab === 'conversations'
- ? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
- : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
- ]"
+ ? 'bg-indigo-600 text-white dark:bg-indigo-500'
+ : 'text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
+ "
@click="tab = 'conversations'"
>
- {{ $t("messages.conversations") }}
- </div>
- <div
- class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
- :class="[
+ <MaterialDesignIcon icon-name="message-text" class="size-6" />
+ </button>
+ <button
+ type="button"
+ class="p-2 rounded-xl transition-colors"
+ :class="
tab === 'announces'
- ? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
- : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
- ]"
+ ? 'bg-indigo-600 text-white dark:bg-indigo-500'
+ : 'text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
+ "
@click="tab = 'announces'"
>
- {{ $t("messages.announces") }}
- </div>
+ <MaterialDesignIcon icon-name="account-search-outline" class="size-6" />
+ </button>
</div>
- </div>
-
- <!-- conversations -->
- <div
- v-if="tab === 'conversations'"
- class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
- >
- <!-- Folders Section -->
- <div class="border-b border-gray-200 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-900/50">
- <div
- class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- @click="foldersExpanded = !foldersExpanded"
+ <div
+ v-if="tab === 'conversations'"
+ class="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden flex flex-col items-center gap-1 py-1 px-0.5"
+ >
+ <button
+ v-for="c in collapsedSidebarConversations"
+ :key="c.destination_hash"
+ type="button"
+ class="shrink-0 p-0.5 rounded-xl transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
+ :class="
+ selectedDestinationHash === c.destination_hash
+ ? 'ring-2 ring-indigo-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
+ : 'hover:bg-white/10'
+ "
+ :title="c.custom_display_name ?? c.display_name"
+ @click="onConversationClick(c)"
>
- <div class="flex items-center gap-2">
- <MaterialDesignIcon
- :icon-name="foldersExpanded ? 'chevron-down' : 'chevron-right'"
- class="size-4 text-gray-400"
- />
- <span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-zinc-500">
- Folders
- </span>
- </div>
- <div class="flex gap-1" @click.stop>
- <button
- type="button"
- class="p-1 text-gray-400 hover:text-indigo-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
- title="Create Folder"
- @click="createFolder"
+ <LxmfUserIcon
+ :custom-image="c.contact_image"
+ :icon-name="c.lxmf_user_icon ? c.lxmf_user_icon.icon_name : ''"
+ :icon-foreground-colour="c.lxmf_user_icon ? c.lxmf_user_icon.foreground_colour : ''"
+ :icon-background-colour="c.lxmf_user_icon ? c.lxmf_user_icon.background_colour : ''"
+ icon-class="size-9 shrink-0"
+ :icon-style="collapsedConversationIconStyle"
+ />
+ </button>
+ </div>
+ </div>
+ <template v-else>
+ <!-- tabs (h-12 matches App.vue main sidebar collapse row) -->
+ <div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-800">
+ <div class="-mb-px flex h-12 min-w-0 items-stretch">
+ <div class="flex min-w-0 flex-1">
+ <div
+ class="flex w-full cursor-pointer items-center justify-center border-b-2 px-1 text-center text-sm font-semibold uppercase tracking-wide transition"
+ :class="[
+ tab === 'conversations'
+ ? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
+ : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
+ ]"
+ @click="tab = 'conversations'"
>
- <MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
- </button>
- <div class="relative">
+ {{ $t("messages.conversations") }}
+ </div>
+ <div
+ class="flex w-full cursor-pointer items-center justify-center border-b-2 px-1 text-center text-sm font-semibold uppercase tracking-wide transition"
+ :class="[
+ tab === 'announces'
+ ? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
+ : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
+ ]"
+ @click="tab = 'announces'"
+ >
+ {{ $t("messages.announces") }}
+ </div>
+ </div>
+ <button
+ type="button"
+ class="hidden sm:flex shrink-0 items-center border-b-2 border-transparent px-1.5 text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
+ @click="$emit('toggle-collapse')"
+ >
+ <MaterialDesignIcon icon-name="chevron-left" class="size-5" />
+ </button>
+ </div>
+ </div>
+
+ <!-- conversations -->
+ <div
+ v-if="tab === 'conversations'"
+ class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800 overflow-hidden min-h-0"
+ >
+ <!-- Folders Section -->
+ <div class="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950">
+ <div
+ class="flex cursor-pointer items-center justify-between px-3 py-2 transition-colors hover:bg-gray-100 dark:hover:bg-zinc-800/80"
+ @click="foldersExpanded = !foldersExpanded"
+ >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon
+ :icon-name="foldersExpanded ? 'chevron-down' : 'chevron-right'"
+ class="size-4 text-gray-400"
+ />
+ <span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-zinc-500">
+ Folders
+ </span>
+ </div>
+ <div class="flex gap-1" @click.stop>
<button
type="button"
- class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
- @click="folderMenu.show = !folderMenu.show"
+ class="p-1 text-gray-400 hover:text-indigo-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ title="Create Folder"
+ @click="createFolder"
>
- <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
+ <MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
</button>
- <div
- v-if="folderMenu.show"
- v-click-outside="{ handler: () => (folderMenu.show = false), capture: true }"
- class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
- >
+ <div class="relative">
<button
type="button"
- class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
- @click="
- $emit('export-folders');
- folderMenu.show = false;
- "
+ class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ @click="folderMenu.show = !folderMenu.show"
>
- <MaterialDesignIcon icon-name="export" class="size-4" />
- <span>Export Folders</span>
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
+ <div
+ v-if="folderMenu.show"
+ v-click-outside="{ handler: () => (folderMenu.show = false), capture: true }"
+ class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
+ >
+ <button
+ type="button"
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="
+ $emit('export-folders');
+ folderMenu.show = false;
+ "
+ >
+ <MaterialDesignIcon icon-name="export" class="size-4" />
+ <span>Export Folders</span>
+ </button>
+ <button
+ type="button"
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="
+ $emit('import-folders');
+ folderMenu.show = false;
+ "
+ >
+ <MaterialDesignIcon icon-name="import" class="size-4" />
+ <span>Import Folders</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
+ <div
+ class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === null
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === 'all'
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', null)"
+ @dragover="onDragOver($event, 'all')"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, null)"
+ >
+ <MaterialDesignIcon icon-name="inbox-outline" class="size-4" />
+ <span class="truncate flex-1">All Messages</span>
+ </div>
+ <div
+ class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === 0
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === 0
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', 0)"
+ @dragover="onDragOver($event, 0)"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, 0)"
+ >
+ <MaterialDesignIcon icon-name="folder-outline" class="size-4" />
+ <span class="truncate flex-1">Uncategorized</span>
+ </div>
+ <div
+ v-for="folder in folders"
+ :key="folder.id"
+ class="group px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === folder.id
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === folder.id
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', folder.id)"
+ @dragover="onDragOver($event, folder.id)"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, folder.id)"
+ >
+ <MaterialDesignIcon icon-name="folder" class="size-4" />
+ <span class="truncate flex-1">{{ folder.name }}</span>
+ <div class="hidden group-hover:flex items-center gap-0.5">
<button
type="button"
- class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
- @click="
- $emit('import-folders');
- folderMenu.show = false;
- "
+ class="p-1 hover:text-blue-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ @click.stop="renameFolder(folder)"
+ >
+ <MaterialDesignIcon icon-name="pencil-outline" class="size-3.5" />
+ </button>
+ <button
+ type="button"
+ class="p-1 hover:text-red-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ @click.stop="deleteFolder(folder)"
>
- <MaterialDesignIcon icon-name="import" class="size-4" />
- <span>Import Folders</span>
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
</button>
</div>
</div>
</div>
</div>
- <div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
- <div
- class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
- :class="[
- selectedFolderId === null
- ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
- : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
- dragOverFolderId === 'all'
- ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
- : '',
- ]"
- @click="$emit('folder-click', null)"
- @dragover="onDragOver($event, 'all')"
- @dragleave="onDragLeave"
- @drop="onDropOnFolder($event, null)"
- >
- <MaterialDesignIcon icon-name="inbox-outline" class="size-4" />
- <span class="truncate flex-1">All Messages</span>
- </div>
- <div
- class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
- :class="[
- selectedFolderId === 0
- ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
- : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
- dragOverFolderId === 0
- ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
- : '',
- ]"
- @click="$emit('folder-click', 0)"
- @dragover="onDragOver($event, 0)"
- @dragleave="onDragLeave"
- @drop="onDropOnFolder($event, 0)"
- >
- <MaterialDesignIcon icon-name="folder-outline" class="size-4" />
- <span class="truncate flex-1">Uncategorized</span>
- </div>
- <div
- v-for="folder in folders"
- :key="folder.id"
- class="group px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
- :class="[
- selectedFolderId === folder.id
- ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
- : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
- dragOverFolderId === folder.id
- ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
- : '',
- ]"
- @click="$emit('folder-click', folder.id)"
- @dragover="onDragOver($event, folder.id)"
- @dragleave="onDragLeave"
- @drop="onDropOnFolder($event, folder.id)"
- >
- <MaterialDesignIcon icon-name="folder" class="size-4" />
- <span class="truncate flex-1">{{ folder.name }}</span>
- <div class="hidden group-hover:flex items-center gap-0.5">
- <button
- type="button"
- class="p-1 hover:text-blue-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
- @click.stop="renameFolder(folder)"
- >
- <MaterialDesignIcon icon-name="pencil-outline" class="size-3.5" />
- </button>
- <button
- type="button"
- class="p-1 hover:text-red-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
- @click.stop="deleteFolder(folder)"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
- </button>
- </div>
- </div>
- </div>
- </div>
- <!-- search + filters -->
- <div
- v-if="conversations.length > 0 || isFilterActive"
- class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-1.5"
- >
- <div class="flex gap-2">
- <input
- :value="conversationSearchTerm"
- type="text"
- :placeholder="$t('messages.search_placeholder', { count: conversations.length })"
- class="input-field flex-1 w-full rounded-none"
- @input="onConversationSearchInput"
- />
- </div>
- <div class="flex items-center justify-end gap-1 px-1 text-gray-500 dark:text-gray-400">
- <button
- type="button"
- class="p-1 rounded-lg hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
- title="Selection Mode"
- :class="{ 'text-blue-500 dark:text-blue-400': selectionMode }"
- @click="toggleSelectionMode"
- >
- <MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
- </button>
- </div>
+ <!-- search + filters -->
<div
- v-if="selectionMode"
- class="flex items-center justify-between px-2 py-1 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
+ v-if="conversations.length > 0 || isFilterActive"
+ class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-1.5"
>
- <div class="flex items-center gap-2">
+ <div class="flex gap-2">
<input
- type="checkbox"
- :checked="allSelected"
- class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- @change="toggleSelectAll"
+ :value="conversationSearchTerm"
+ type="text"
+ :placeholder="$t('messages.search_placeholder', { count: conversations.length })"
+ class="input-field flex-1 w-full rounded-none"
+ @input="onConversationSearchInput"
/>
- <span class="text-xs font-semibold text-blue-700 dark:text-blue-400">
- {{ selectedHashes.size }} selected
- </span>
</div>
- <div class="flex gap-2">
+ <div class="flex items-center justify-end gap-1 px-1 text-gray-500 dark:text-gray-400">
<button
type="button"
- class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
- @click="bulkMarkAsRead"
+ class="p-1 rounded-lg hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
+ title="Selection Mode"
+ :class="{ 'text-blue-500 dark:text-blue-400': selectionMode }"
+ @click="toggleSelectionMode"
>
- Mark as read
+ <MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
</button>
- <button
- type="button"
- class="text-xs font-bold text-red-600 dark:text-red-400 hover:underline"
- @click="bulkDelete"
- >
- Delete
- </button>
- <div class="relative">
+ </div>
+ <div
+ v-if="selectionMode"
+ class="flex items-center justify-between px-2 py-1 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
+ >
+ <div class="flex items-center gap-2">
+ <input
+ type="checkbox"
+ :checked="allSelected"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ @change="toggleSelectAll"
+ />
+ <span class="text-xs font-semibold text-blue-700 dark:text-blue-400">
+ {{ selectedHashes.size }} selected
+ </span>
+ </div>
+ <div class="flex gap-2">
<button
type="button"
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
- @click="moveMenu.show = !moveMenu.show"
+ @click="bulkMarkAsRead"
>
- Move to
+ Mark as read
</button>
- <div
- v-if="moveMenu.show"
- v-click-outside="{ handler: () => (moveMenu.show = false), capture: true }"
- class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
+ <button
+ type="button"
+ class="text-xs font-bold text-red-600 dark:text-red-400 hover:underline"
+ @click="bulkDelete"
>
+ Delete
+ </button>
+ <div class="relative">
<button
type="button"
- class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
- @click="moveSelectedToFolder(null)"
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
+ @click="moveMenu.show = !moveMenu.show"
>
- Uncategorized
+ Move to
</button>
- <button
- v-for="folder in folders"
- :key="folder.id"
- type="button"
- class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
- @click="moveSelectedToFolder(folder.id)"
+ <div
+ v-if="moveMenu.show"
+ v-click-outside="{ handler: () => (moveMenu.show = false), capture: true }"
+ class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
>
- {{ folder.name }}
- </button>
+ <button
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="moveSelectedToFolder(null)"
+ >
+ Uncategorized
+ </button>
+ <button
+ v-for="folder in folders"
+ :key="folder.id"
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="moveSelectedToFolder(folder.id)"
+ >
+ {{ folder.name }}
+ </button>
+ </div>
</div>
</div>
</div>
+ <div class="flex flex-wrap gap-1">
+ <button
+ type="button"
+ :class="filterChipClasses(filterUnreadOnly)"
+ @click="toggleFilter('unread')"
+ >
+ {{ $t("messages.unread") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClasses(filterFailedOnly)"
+ @click="toggleFilter('failed')"
+ >
+ {{ $t("messages.failed") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClasses(filterHasAttachmentsOnly)"
+ @click="toggleFilter('attachments')"
+ >
+ {{ $t("messages.attachments") }}
+ </button>
+ </div>
</div>
- <div class="flex flex-wrap gap-1">
- <button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
- {{ $t("messages.unread") }}
- </button>
- <button type="button" :class="filterChipClasses(filterFailedOnly)" @click="toggleFilter('failed')">
- {{ $t("messages.failed") }}
- </button>
- <button
- type="button"
- :class="filterChipClasses(filterHasAttachmentsOnly)"
- @click="toggleFilter('attachments')"
- >
- {{ $t("messages.attachments") }}
- </button>
- </div>
- </div>
- <!-- conversations -->
- <div class="flex h-full overflow-y-auto" @scroll="onConversationsScroll">
- <div v-if="isLoading" class="w-full divide-y divide-gray-100 dark:divide-zinc-800">
- <div v-for="i in 6" :key="i" class="p-3 animate-pulse">
- <div class="flex gap-3">
- <div class="rounded bg-gray-200 dark:bg-zinc-800" :style="messageIconStyle"></div>
- <div class="flex-1 space-y-2 py-1">
- <div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-3/4"></div>
- <div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-1/2"></div>
+ <!-- conversations -->
+ <div class="flex h-full overflow-y-auto" @scroll="onConversationsScroll">
+ <div v-if="isLoading" class="w-full divide-y divide-gray-100 dark:divide-zinc-800">
+ <div v-for="i in 6" :key="i" class="p-3 animate-pulse">
+ <div class="flex gap-3">
+ <div class="rounded bg-gray-200 dark:bg-zinc-800" :style="messageIconStyle"></div>
+ <div class="flex-1 space-y-2 py-1">
+ <div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-3/4"></div>
+ <div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-1/2"></div>
+ </div>
</div>
</div>
</div>
- </div>
- <div v-else-if="displayedConversations.length > 0" class="w-full">
- <div
- v-for="conversation of displayedConversations"
- :key="conversation.destination_hash"
- v-memo="[
- conversation.destination_hash,
- conversation.updated_at,
- conversation.is_unread,
- conversation.failed_messages_count,
- selectedDestinationHash === conversation.destination_hash,
- GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
- selectionMode,
- selectedHashes.has(conversation.destination_hash),
- pinnedSet.has(conversation.destination_hash),
- timeAgoTick,
- ]"
- class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
- :class="[
- conversation.destination_hash === selectedDestinationHash
- ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
- : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
- selectedHashes.has(conversation.destination_hash)
- ? 'bg-blue-50/50 dark:bg-blue-900/10'
- : '',
- ]"
- draggable="true"
- @click="
- selectionMode
- ? toggleSelectConversation(conversation.destination_hash)
- : onConversationClick(conversation)
- "
- @contextmenu="onRightClick($event, conversation.destination_hash)"
- @dragstart="onDragStart($event, conversation.destination_hash)"
- >
- <!-- Selection Checkbox -->
- <div v-if="selectionMode" class="my-auto mr-3 px-1">
- <input
- type="checkbox"
- :checked="selectedHashes.has(conversation.destination_hash)"
- class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- @click.stop
- @change="toggleSelectConversation(conversation.destination_hash)"
- />
- </div>
-
- <!-- banished overlay -->
+ <div v-else-if="displayedConversations.length > 0" class="w-full">
<div
- v-if="
- GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash)
+ v-for="conversation of displayedConversations"
+ :key="conversation.destination_hash"
+ v-memo="[
+ conversation.destination_hash,
+ conversation.updated_at,
+ conversation.is_unread,
+ conversation.failed_messages_count,
+ selectedDestinationHash === conversation.destination_hash,
+ GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
+ selectionMode,
+ selectedHashes.has(conversation.destination_hash),
+ pinnedSet.has(conversation.destination_hash),
+ timeAgoTick,
+ ]"
+ class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
+ :class="[
+ conversation.destination_hash === selectedDestinationHash
+ ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
+ : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
+ selectedHashes.has(conversation.destination_hash)
+ ? 'bg-blue-50/50 dark:bg-blue-900/10'
+ : '',
+ ]"
+ draggable="true"
+ @click="
+ selectionMode
+ ? toggleSelectConversation(conversation.destination_hash)
+ : onConversationClick(conversation)
"
- class="banished-overlay"
- :style="{ background: GlobalState.config.banished_color + '33' }"
+ @contextmenu="onRightClick($event, conversation.destination_hash)"
+ @dragstart="onDragStart($event, conversation.destination_hash)"
>
- <span
- class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
- :style="{ 'background-color': GlobalState.config.banished_color }"
- >{{ GlobalState.config.banished_text }}</span
- >
- </div>
+ <!-- Selection Checkbox -->
+ <div v-if="selectionMode" class="my-auto mr-3 px-1">
+ <input
+ type="checkbox"
+ :checked="selectedHashes.has(conversation.destination_hash)"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ @click.stop
+ @change="toggleSelectConversation(conversation.destination_hash)"
+ />
+ </div>
- <div class="my-auto mr-2">
- <LxmfUserIcon
- :custom-image="conversation.contact_image"
- :icon-name="conversation.lxmf_user_icon ? conversation.lxmf_user_icon.icon_name : ''"
- :icon-foreground-colour="
- conversation.lxmf_user_icon ? conversation.lxmf_user_icon.foreground_colour : ''
- "
- :icon-background-colour="
- conversation.lxmf_user_icon ? conversation.lxmf_user_icon.background_colour : ''
+ <!-- banished overlay -->
+ <div
+ v-if="
+ GlobalState.config.banished_effect_enabled &&
+ isBlocked(conversation.destination_hash)
"
- icon-class="shrink-0"
- :icon-style="messageIconStyle"
- />
- </div>
- <div class="mr-auto w-full pr-2 min-w-0">
- <div class="flex justify-between gap-2 min-w-0">
- <div
- class="text-gray-900 dark:text-gray-100 truncate min-w-0"
- :title="conversation.custom_display_name ?? conversation.display_name"
- :class="{
- 'font-semibold':
- (conversation.is_unread &&
- conversation.destination_hash !== selectedDestinationHash) ||
- conversation.failed_messages_count > 0,
- }"
+ class="banished-overlay"
+ :style="{ background: GlobalState.config.banished_color + '33' }"
+ >
+ <span
+ class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
+ :style="{ 'background-color': GlobalState.config.banished_color }"
+ >{{ GlobalState.config.banished_text }}</span
>
- {{ conversation.custom_display_name ?? conversation.display_name }}
- </div>
- <div class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0">
- {{ formatTimeAgo(conversation.updated_at) }}
- </div>
</div>
- <div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
- {{
- stripMarkdown(
- conversation.latest_message_preview ?? conversation.latest_message_title
- ) ?? "No messages yet"
- }}
+
+ <div class="my-auto mr-2">
+ <LxmfUserIcon
+ :custom-image="conversation.contact_image"
+ :icon-name="
+ conversation.lxmf_user_icon ? conversation.lxmf_user_icon.icon_name : ''
+ "
+ :icon-foreground-colour="
+ conversation.lxmf_user_icon ? conversation.lxmf_user_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ conversation.lxmf_user_icon ? conversation.lxmf_user_icon.background_colour : ''
+ "
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
+ />
</div>
- </div>
- <div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
- <div class="flex items-center space-x-1">
- <div
- v-if="pinnedSet.has(conversation.destination_hash)"
- class="text-blue-500 dark:text-blue-400"
- title="Pinned"
- >
- <MaterialDesignIcon icon-name="pin" class="w-4 h-4" />
+ <div class="mr-auto w-full pr-2 min-w-0">
+ <div class="flex justify-between gap-2 min-w-0">
+ <div
+ class="text-gray-900 dark:text-gray-100 truncate min-w-0"
+ :title="conversation.custom_display_name ?? conversation.display_name"
+ :class="{
+ 'font-semibold':
+ (conversation.is_unread &&
+ conversation.destination_hash !== selectedDestinationHash) ||
+ conversation.failed_messages_count > 0,
+ }"
+ >
+ {{ conversation.custom_display_name ?? conversation.display_name }}
+ </div>
+ <div
+ class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0"
+ >
+ {{ formatTimeAgo(conversation.updated_at) }}
+ </div>
</div>
- <div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
- <MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
- </div>
- <div
- v-if="
- conversation.is_unread &&
- conversation.destination_hash !== selectedDestinationHash
- "
- class="my-auto ml-1"
- >
- <div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
+ <div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
+ {{
+ stripMarkdown(
+ conversation.latest_message_preview ?? conversation.latest_message_title
+ ) ?? "No messages yet"
+ }}
</div>
- <div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
- <div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
+ </div>
+ <div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
+ <div class="flex items-center space-x-1">
+ <div
+ v-if="pinnedSet.has(conversation.destination_hash)"
+ class="text-blue-500 dark:text-blue-400"
+ title="Pinned"
+ >
+ <MaterialDesignIcon icon-name="pin" class="w-4 h-4" />
+ </div>
+ <div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
+ <MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
+ </div>
+ <div
+ v-if="
+ conversation.is_unread &&
+ conversation.destination_hash !== selectedDestinationHash
+ "
+ class="my-auto ml-1"
+ >
+ <div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
+ </div>
+ <div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
+ <div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
+ </div>
</div>
+ <button
+ type="button"
+ class="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-zinc-800 rounded-lg transition-all"
+ @click.stop="onRightClick($event, conversation.destination_hash)"
+ >
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
+ </button>
</div>
- <button
- type="button"
- class="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-zinc-800 rounded-lg transition-all"
- @click.stop="onRightClick($event, conversation.destination_hash)"
- >
- <MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
- </button>
</div>
- </div>
- <!-- Context Menu -->
- <div
- v-if="contextMenu.show"
- v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
- class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
- >
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="bulkMarkAsRead"
- >
- <MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
- <span class="font-medium">Mark as Read</span>
- </button>
- <button
- v-if="contextMenu.targetHash"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="togglePinFromContextMenu"
- >
- <MaterialDesignIcon
- :icon-name="isContextTargetPinned ? 'pin-off' : 'pin'"
- class="size-4 text-gray-400"
- />
- <span class="font-medium">{{
- isContextTargetPinned
- ? $t("messages.unpin_conversation")
- : $t("messages.pin_conversation")
- }}</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="contextMenuIngestPaperMessage"
- >
- <MaterialDesignIcon icon-name="qrcode-scan" class="size-4 text-gray-400" />
- <span class="font-medium">{{ $t("messages.ingest_paper_message") }}</span>
- </button>
- <button
- v-if="contextMenu.targetHash && isBlocked(contextMenu.targetHash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
- @click="liftBanishmentFromConversationMenu"
- >
- <MaterialDesignIcon icon-name="check-circle" class="size-4" />
- <span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <button
- v-if="GlobalState.config.telemetry_enabled"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="toggleTelemetryTrust(contextMenu.targetHash)"
- >
- <MaterialDesignIcon
- :icon-name="
- contextMenu.targetContact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'
- "
- :class="
- contextMenu.targetContact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-400'
- "
- class="size-4"
- />
- <span class="font-medium">{{
- contextMenu.targetContact?.is_telemetry_trusted
- ? "Revoke Telemetry Trust"
- : "Trust for Telemetry"
- }}</span>
- </button>
+ <!-- Context Menu -->
<div
- v-if="GlobalState.config.telemetry_enabled"
- class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
- ></div>
- <div
- class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
- >
- Move to Folder
- </div>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
- @click="moveSelectedToFolder(null)"
+ v-if="contextMenu.show"
+ v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
+ class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
+ :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
- <MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
- <span>Uncategorized</span>
- </button>
- <div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<button
- v-for="folder in folders"
- :key="folder.id"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="bulkMarkAsRead"
+ >
+ <MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
+ <span class="font-medium">Mark as Read</span>
+ </button>
+ <button
+ v-if="contextMenu.targetHash"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="togglePinFromContextMenu"
+ >
+ <MaterialDesignIcon
+ :icon-name="isContextTargetPinned ? 'pin-off' : 'pin'"
+ class="size-4 text-gray-400"
+ />
+ <span class="font-medium">{{
+ isContextTargetPinned
+ ? $t("messages.unpin_conversation")
+ : $t("messages.pin_conversation")
+ }}</span>
+ </button>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="contextMenuIngestPaperMessage"
+ >
+ <MaterialDesignIcon icon-name="qrcode-scan" class="size-4 text-gray-400" />
+ <span class="font-medium">{{ $t("messages.ingest_paper_message") }}</span>
+ </button>
+ <button
+ v-if="contextMenu.targetHash && isBlocked(contextMenu.targetHash)"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
+ @click="liftBanishmentFromConversationMenu"
+ >
+ <MaterialDesignIcon icon-name="check-circle" class="size-4" />
+ <span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
+ </button>
+ <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <button
+ v-if="GlobalState.config.telemetry_enabled"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="toggleTelemetryTrust(contextMenu.targetHash)"
+ >
+ <MaterialDesignIcon
+ :icon-name="
+ contextMenu.targetContact?.is_telemetry_trusted
+ ? 'shield-check'
+ : 'shield-outline'
+ "
+ :class="
+ contextMenu.targetContact?.is_telemetry_trusted
+ ? 'text-blue-500'
+ : 'text-gray-400'
+ "
+ class="size-4"
+ />
+ <span class="font-medium">{{
+ contextMenu.targetContact?.is_telemetry_trusted
+ ? "Revoke Telemetry Trust"
+ : "Trust for Telemetry"
+ }}</span>
+ </button>
+ <div
+ v-if="GlobalState.config.telemetry_enabled"
+ class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
+ ></div>
+ <div
+ class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ >
+ Move to Folder
+ </div>
+ <button
type="button"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
- @click="moveSelectedToFolder(folder.id)"
+ @click="moveSelectedToFolder(null)"
>
- <MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
- <span class="truncate">{{ folder.name }}</span>
+ <MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
+ <span>Uncategorized</span>
+ </button>
+ <div class="max-h-[200px] overflow-y-auto custom-scrollbar">
+ <button
+ v-for="folder in folders"
+ :key="folder.id"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
+ @click="moveSelectedToFolder(folder.id)"
+ >
+ <MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
+ <span class="truncate">{{ folder.name }}</span>
+ </button>
+ </div>
+ <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ @click="bulkDelete"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
+ <span class="font-bold">Delete</span>
</button>
</div>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="bulkDelete"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- <span class="font-bold">Delete</span>
- </button>
- </div>
- <!-- loading more spinner -->
- <div v-if="isLoadingMore" class="p-4 text-center">
- <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
- </div>
- </div>
- <div v-else class="mx-auto my-auto text-center leading-5">
- <div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
- <div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin" />
+ <!-- loading more spinner -->
+ <div v-if="isLoadingMore" class="p-4 text-center">
+ <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
- <div class="font-semibold">{{ $t("messages.loading_conversations") }}</div>
</div>
+ <div v-else class="mx-auto my-auto text-center leading-5">
+ <div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin" />
+ </div>
+ <div class="font-semibold">{{ $t("messages.loading_conversations") }}</div>
+ </div>
- <!-- no conversations at all -->
- <div
- v-else-if="conversations.length === 0 && !isFilterActive"
- class="flex flex-col text-gray-900 dark:text-gray-100"
- >
- <div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="tray-remove" class="size-6" />
+ <!-- no conversations at all -->
+ <div
+ v-else-if="conversations.length === 0 && !isFilterActive"
+ class="flex flex-col text-gray-900 dark:text-gray-100"
+ >
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="tray-remove" class="size-6" />
+ </div>
+ <div class="font-semibold">No Conversations</div>
+ <div>Discover peers on the Announces tab</div>
</div>
- <div class="font-semibold">No Conversations</div>
- <div>Discover peers on the Announces tab</div>
- </div>
- <!-- is searching or filtering, but no results -->
- <div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
- <div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="magnify-close" class="size-6" />
+ <!-- is searching or filtering, but no results -->
+ <div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="magnify-close" class="size-6" />
+ </div>
+ <div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
+ <div>{{ $t("messages.no_search_results_conversations") }}</div>
</div>
- <div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
- <div>{{ $t("messages.no_search_results_conversations") }}</div>
</div>
</div>
</div>
- </div>
- <!-- discover -->
- <div
- v-if="tab === 'announces'"
- class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
- >
- <!-- search -->
- <div class="p-1 border-b border-gray-300 dark:border-zinc-700">
- <input
- :value="peersSearchTerm"
- type="text"
- :placeholder="$t('messages.search_placeholder_announces', { count: totalPeersCount })"
- class="input-field"
- @input="onPeersSearchInput"
- />
- </div>
+ <!-- discover -->
+ <div
+ v-if="tab === 'announces'"
+ class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800 overflow-hidden min-h-0"
+ >
+ <!-- search -->
+ <div class="p-1 border-b border-gray-200 dark:border-zinc-800">
+ <input
+ :value="peersSearchTerm"
+ type="text"
+ :placeholder="$t('messages.search_placeholder_announces', { count: totalPeersCount })"
+ class="input-field"
+ @input="onPeersSearchInput"
+ />
+ </div>
- <!-- peers -->
- <div class="flex h-full overflow-y-auto" @scroll="onPeersScroll">
- <div v-if="searchedPeers.length > 0" class="w-full">
- <div
- v-for="peer of searchedPeers"
- :key="peer.destination_hash"
- v-memo="[
- peer.destination_hash,
- peer.updated_at,
- peer.hops,
- peer.snr,
- selectedDestinationHash === peer.destination_hash,
- GlobalState.config.banished_effect_enabled && isBlocked(peer.destination_hash),
- timeAgoTick,
- ]"
- class="flex cursor-pointer p-2 border-l-2 relative"
- :class="[
- peer.destination_hash === selectedDestinationHash
- ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
- : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
- ]"
- @click="onPeerClick(peer)"
- >
- <!-- banished overlay -->
+ <!-- peers -->
+ <div class="flex h-full overflow-y-auto" @scroll="onPeersScroll">
+ <div v-if="searchedPeers.length > 0" class="w-full">
<div
- v-if="GlobalState.config.banished_effect_enabled && isBlocked(peer.destination_hash)"
- class="banished-overlay"
- :style="{ background: GlobalState.config.banished_color + '33' }"
+ v-for="peer of searchedPeers"
+ :key="peer.destination_hash"
+ v-memo="[
+ peer.destination_hash,
+ peer.updated_at,
+ peer.hops,
+ peer.snr,
+ selectedDestinationHash === peer.destination_hash,
+ GlobalState.config.banished_effect_enabled && isBlocked(peer.destination_hash),
+ timeAgoTick,
+ ]"
+ class="flex cursor-pointer p-2 border-l-2 relative"
+ :class="[
+ peer.destination_hash === selectedDestinationHash
+ ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
+ : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
+ ]"
+ @click="onPeerClick(peer)"
>
- <span
- class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
- :style="{ 'background-color': GlobalState.config.banished_color }"
- >{{ GlobalState.config.banished_text }}</span
- >
- </div>
-
- <div class="my-auto mr-2">
- <LxmfUserIcon
- :custom-image="peer.contact_image"
- :icon-name="peer.lxmf_user_icon?.icon_name"
- :icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
- :icon-background-colour="peer.lxmf_user_icon?.background_colour"
- icon-class="shrink-0"
- :icon-style="messageIconStyle"
- />
- </div>
- <div class="min-w-0 flex-1">
+ <!-- banished overlay -->
<div
- class="text-gray-900 dark:text-gray-100 truncate"
- :title="peer.custom_display_name ?? peer.display_name"
+ v-if="GlobalState.config.banished_effect_enabled && isBlocked(peer.destination_hash)"
+ class="banished-overlay"
+ :style="{ background: GlobalState.config.banished_color + '33' }"
>
- {{ peer.custom_display_name ?? peer.display_name }}
+ <span
+ class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
+ :style="{ 'background-color': GlobalState.config.banished_color }"
+ >{{ GlobalState.config.banished_text }}</span
+ >
</div>
- <div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
- <!-- time ago -->
- <span class="flex my-auto space-x-1">
- {{ formatTimeAgo(peer.updated_at) }}
- </span>
- <!-- hops away -->
- <span
- v-if="peer.hops != null && peer.hops !== 128"
- class="flex my-auto text-sm text-gray-500 space-x-1"
+ <div class="my-auto mr-2">
+ <LxmfUserIcon
+ :custom-image="peer.contact_image"
+ :icon-name="peer.lxmf_user_icon?.icon_name"
+ :icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
+ :icon-background-colour="peer.lxmf_user_icon?.background_colour"
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
+ />
+ </div>
+ <div class="min-w-0 flex-1">
+ <div
+ class="text-gray-900 dark:text-gray-100 truncate"
+ :title="peer.custom_display_name ?? peer.display_name"
>
- <span>•</span>
- <span v-if="peer.hops === 0 || peer.hops === 1">{{ $t("messages.direct") }}</span>
- <span v-else>{{ $t("messages.hops", { count: peer.hops }) }}</span>
- </span>
+ {{ peer.custom_display_name ?? peer.display_name }}
+ </div>
+ <div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
+ <!-- time ago -->
+ <span class="flex my-auto space-x-1">
+ {{ formatTimeAgo(peer.updated_at) }}
+ </span>
+
+ <!-- hops away -->
+ <span
+ v-if="peer.hops != null && peer.hops !== 128"
+ class="flex my-auto text-sm text-gray-500 space-x-1"
+ >
+ <span>•</span>
+ <span v-if="peer.hops === 0 || peer.hops === 1">{{
+ $t("messages.direct")
+ }}</span>
+ <span v-else>{{ $t("messages.hops", { count: peer.hops }) }}</span>
+ </span>
- <!-- snr -->
- <span v-if="peer.snr != null" class="flex my-auto space-x-1">
- <span>•</span>
- <span>{{ $t("messages.snr", { snr: peer.snr }) }}</span>
- </span>
+ <!-- snr -->
+ <span v-if="peer.snr != null" class="flex my-auto space-x-1">
+ <span>•</span>
+ <span>{{ $t("messages.snr", { snr: peer.snr }) }}</span>
+ </span>
+ </div>
</div>
</div>
- </div>
- <!-- loading more spinner -->
- <div v-if="isLoadingMoreAnnounces" class="p-4 text-center">
- <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
- </div>
- </div>
- <div v-else class="mx-auto my-auto text-center leading-5">
- <!-- no peers at all -->
- <div v-if="peersCount === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
- <div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="account-search-outline" class="size-6" />
+ <!-- loading more spinner -->
+ <div v-if="isLoadingMoreAnnounces" class="p-4 text-center">
+ <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
- <div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
- <div>{{ $t("messages.waiting_for_announce") }}</div>
</div>
+ <div v-else class="mx-auto my-auto text-center leading-5">
+ <!-- no peers at all -->
+ <div v-if="peersCount === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="account-search-outline" class="size-6" />
+ </div>
+ <div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
+ <div>{{ $t("messages.waiting_for_announce") }}</div>
+ </div>
- <!-- is searching, but no results -->
- <div
- v-if="peersSearchTerm !== '' && peersCount > 0"
- class="flex flex-col text-gray-900 dark:text-gray-100"
- >
- <div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="account-off-outline" class="size-6" />
+ <!-- is searching, but no results -->
+ <div
+ v-if="peersSearchTerm !== '' && peersCount > 0"
+ class="flex flex-col text-gray-900 dark:text-gray-100"
+ >
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="account-off-outline" class="size-6" />
+ </div>
+ <div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
+ <div>{{ $t("messages.no_search_results_peers") }}</div>
</div>
- <div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
- <div>{{ $t("messages.no_search_results_peers") }}</div>
</div>
</div>
</div>
- </div>
+ </template>
</div>
</template>
@@ -779,6 +878,10 @@ export default {
type: Array,
default: () => [],
},
+ collapsed: {
+ type: Boolean,
+ default: false,
+ },
},
emits: [
"conversation-click",
@@ -799,6 +902,7 @@ export default {
"export-folders",
"import-folders",
"toggle-conversation-pin",
+ "toggle-collapse",
],
data() {
let foldersExpanded = true;
@@ -831,9 +935,25 @@ export default {
},
draggedHash: null,
dragOverFolderId: null,
+ smUp: typeof window !== "undefined" ? window.innerWidth >= 640 : true,
};
},
computed: {
+ effectiveCollapsed() {
+ return this.collapsed && this.smUp;
+ },
+ collapsedSidebarConversations() {
+ return this.displayedConversations.slice(0, 5);
+ },
+ collapsedConversationIconStyle() {
+ return { width: "36px", height: "36px" };
+ },
+ sidebarRootClass() {
+ if (this.effectiveCollapsed) {
+ return "flex flex-col w-16 min-w-16 max-w-16 h-full min-h-0";
+ }
+ return "flex flex-col w-full sm:w-80 sm:min-w-80 md:max-lg:w-64 md:max-lg:min-w-64 lg:w-80 lg:min-w-80 min-h-0";
+ },
isFilterActive() {
return (
this.conversationSearchTerm !== "" ||
@@ -918,9 +1038,18 @@ export default {
this._timeAgoInterval = setInterval(() => {
this.timeAgoTick = Date.now();
}, tickMs);
+ this._smUpMql = window.matchMedia("(min-width: 640px)");
+ this._smUpResize = () => {
+ this.smUp = this._smUpMql.matches;
+ };
+ this._smUpResize();
+ this._smUpMql.addEventListener("change", this._smUpResize);
},
unmounted() {
GlobalEmitter.off("contact-updated", this.onContactUpdated);
+ if (this._smUpMql && this._smUpResize) {
+ this._smUpMql.removeEventListener("change", this._smUpResize);
+ }
if (this._timeAgoInterval) clearInterval(this._timeAgoInterval);
},
methods: {

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index ff907891..79bc6de0 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -246,7 +246,7 @@
v-model="searchQuery"
type="text"
:placeholder="`Search nodes (${nodes.length})...`"
- class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
+ class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
/>
<button
v-if="searchQuery"

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 3f28e57c..d134334a 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -4,6 +4,7 @@
<NomadNetworkSidebar
v-if="!isPopoutMode"
:class="{ 'hidden sm:flex': selectedNode }"
+ :collapsed="nomadNetworkSidebarCollapsed"
:nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash"
@@ -17,10 +18,11 @@
@add-favourite="addFavourite"
@nodes-search-changed="onNodesSearchChanged"
@load-more-nodes="loadMoreNodes"
+ @toggle-collapse="nomadNetworkSidebarCollapsed = !nomadNetworkSidebarCollapsed"
/>
<div
- class="flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950"
+ class="flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950"
:class="selectedNode ? 'flex' : 'hidden sm:flex'"
>
<!-- node -->
@@ -45,7 +47,9 @@
</div>
<!-- header -->
- <div class="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-zinc-800 min-w-0">
+ <div
+ class="flex items-center gap-1 p-2 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 min-w-0"
+ >
<!-- favourite button -->
<div class="my-auto shrink-0 mr-1">
<IconButton
@@ -143,14 +147,14 @@
</div>
<IconButton
- class="md:hidden shrink-0 text-gray-700 dark:text-gray-300"
+ class="lg:hidden shrink-0 text-gray-700 dark:text-gray-300"
:title="$t('nomadnet.identify')"
@click="identify(selectedNode.destination_hash)"
>
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
</IconButton>
- <div class="hidden md:flex items-center gap-1 shrink-0">
+ <div class="hidden lg:flex items-center gap-1 shrink-0">
<IconButton
class="text-gray-700 dark:text-gray-300"
:title="$t('nomadnet.identify')"
@@ -174,7 +178,7 @@
</IconButton>
</div>
- <DropDownMenu class="md:hidden shrink-0">
+ <DropDownMenu class="lg:hidden shrink-0">
<template #button>
<IconButton :title="$t('messages.more_actions')" class="text-gray-700 dark:text-gray-300">
<MaterialDesignIcon icon-name="dots-horizontal" class="size-5" />
@@ -238,7 +242,9 @@
</div>
<!-- page content -->
- <div class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer relative">
+ <div
+ class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer relative [contain:layout_paint]"
+ >
<!-- archived version notice -->
<div
v-if="isShowingArchivedVersion"
@@ -314,8 +320,14 @@
</button>
</div>
</div>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <pre v-else class="h-full break-words whitespace-pre-wrap" v-html="renderedNodePageContent()"></pre>
+ <!-- eslint-disable vue/no-v-html -->
+ <pre
+ v-else
+ v-memo="[renderedNodePageHtml]"
+ class="h-full break-words whitespace-pre-wrap"
+ v-html="renderedNodePageHtml"
+ ></pre>
+ <!-- eslint-enable vue/no-v-html -->
</div>
<!-- file download bottom bar -->
@@ -417,6 +429,7 @@ export default {
nodesRefreshTimeout: null,
abortController: new AbortController(),
+ nomadNetworkSidebarCollapsed: false,
nodes: {},
totalNodesCount: 0,
hasMoreNodes: true,
@@ -465,9 +478,11 @@ export default {
isSelectedNodeBlocked: false,
pagePartials: {},
+ loadedPartialIds: {},
partialIdsByKey: {},
partialRefreshByKey: {},
partialRefreshTimers: {},
+ processPartialsRaf: null,
};
},
computed: {
@@ -505,8 +520,20 @@ export default {
}
return base;
},
+ renderedNodePageHtml() {
+ if (!this.nodePagePath || this.nodePageContent == null) {
+ return "";
+ }
+ return this.renderPageContent(this.nodePagePath, this.nodePageContent);
+ },
},
watch: {
+ renderedNodePageHtml(newVal, oldVal) {
+ if (newVal !== oldVal) {
+ this.loadedPartialIds = {};
+ }
+ this.scheduleProcessPartials();
+ },
selectedNode: {
handler() {
this.checkIfSelectedNodeBlocked();
@@ -520,10 +547,11 @@ export default {
deep: true,
},
},
- updated() {
- this.$nextTick(() => this.processPartials());
- },
beforeUnmount() {
+ if (this.processPartialsRaf != null) {
+ cancelAnimationFrame(this.processPartialsRaf);
+ this.processPartialsRaf = null;
+ }
if (this.nodesRefreshTimeout) clearTimeout(this.nodesRefreshTimeout);
clearInterval(this.reloadInterval);
this.abortController.abort();
@@ -581,8 +609,19 @@ export default {
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
+
+ this.$nextTick(() => this.scheduleProcessPartials());
},
methods: {
+ scheduleProcessPartials() {
+ if (this.processPartialsRaf != null) {
+ cancelAnimationFrame(this.processPartialsRaf);
+ }
+ this.processPartialsRaf = requestAnimationFrame(() => {
+ this.processPartialsRaf = null;
+ this.processPartials();
+ });
+ },
openNomadnetPopout() {
if (!this.selectedNode) {
return;
@@ -1084,6 +1123,7 @@ export default {
Object.values(this.partialRefreshTimers).forEach((t) => clearTimeout(t));
this.partialRefreshTimers = {};
this.pagePartials = {};
+ this.loadedPartialIds = {};
this.partialIdsByKey = {};
this.partialRefreshByKey = {};
},
@@ -1092,6 +1132,9 @@ export default {
return;
const [pagePathWithoutData] = this.nodePagePath.split("`");
if (!pagePathWithoutData.endsWith(".mu")) return;
+ if (!this.nodePageContent.includes("`{")) {
+ return;
+ }
const container = this.$el.querySelector(".nodeContainer");
if (!container) return;
@@ -1129,7 +1172,7 @@ export default {
}
fieldsByKey[key] = fieldData;
}
- if (!this.pagePartials[id]) needLoad.add(key);
+ if (!this.loadedPartialIds[id]) needLoad.add(key);
});
this.partialIdsByKey = idsByKey;
@@ -1160,6 +1203,12 @@ export default {
const ids = this.partialIdsByKey[key];
if (ids) {
updatePartialDom(html, ids);
+ for (const { id } of ids) {
+ if (id) {
+ this.loadedPartialIds[id] = true;
+ }
+ }
+ this.$nextTick(() => this.scheduleProcessPartials());
}
const refreshSec = this.partialRefreshByKey[key];
if (refreshSec != null && refreshSec > 0) {
@@ -1170,6 +1219,12 @@ export default {
const idList = this.partialIdsByKey[key];
if (idList) {
updatePartialDom(h, idList);
+ for (const { id } of idList) {
+ if (id) {
+ this.loadedPartialIds[id] = true;
+ }
+ }
+ this.$nextTick(() => this.scheduleProcessPartials());
}
scheduleRefresh();
});
@@ -1712,9 +1767,6 @@ export default {
console.error(e);
}
},
- renderedNodePageContent() {
- return this.renderPageContent(this.nodePagePath, this.nodePageContent);
- },
cancelPageDownload() {
if (this.currentPageDownloadId !== null) {
WebSocketConnection.send(

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 4c3cfa0b..2d674510 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -1,424 +1,525 @@
<template>
- <div
- class="flex flex-col w-full sm:w-80 sm:min-w-80 min-h-0 bg-white/90 dark:bg-zinc-950/80 backdrop-blur border-r border-gray-200 dark:border-zinc-800"
- >
- <div class="flex">
- <button
- type="button"
- class="sidebar-tab"
- :class="{ 'sidebar-tab--active': tab === 'favourites' }"
- @click="tab = 'favourites'"
+ <div :class="sidebarRootClass">
+ <div
+ v-if="effectiveCollapsed"
+ class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
+ >
+ <div
+ class="hidden sm:flex h-12 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
>
- {{ $t("nomadnet.favourites") }}
- </button>
- <button
- type="button"
- class="sidebar-tab"
- :class="{ 'sidebar-tab--active': tab === 'announces' }"
- @click="tab = 'announces'"
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
+ @click="$emit('toggle-collapse')"
+ >
+ <MaterialDesignIcon icon-name="chevron-right" class="size-5" />
+ </button>
+ </div>
+ <div class="flex flex-col items-center gap-1 py-2 px-1 border-b border-gray-200 dark:border-zinc-800">
+ <button
+ type="button"
+ class="p-2 rounded-xl transition-colors"
+ :class="
+ tab === 'favourites'
+ ? 'bg-blue-600 text-white dark:bg-blue-500'
+ : 'text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
+ "
+ @click="tab = 'favourites'"
+ >
+ <MaterialDesignIcon icon-name="star" class="size-6" />
+ </button>
+ <button
+ type="button"
+ class="p-2 rounded-xl transition-colors"
+ :class="
+ tab === 'announces'
+ ? 'bg-blue-600 text-white dark:bg-blue-500'
+ : 'text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
+ "
+ @click="tab = 'announces'"
+ >
+ <MaterialDesignIcon icon-name="satellite-uplink" class="size-6" />
+ </button>
+ </div>
+ <div
+ v-if="tab === 'favourites'"
+ class="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden flex flex-col items-center gap-1 py-1 px-0.5"
>
- {{ $t("nomadnet.announces") }}
- </button>
- </div>
-
- <div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
- <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
- <input
- v-model="favouritesSearchTerm"
- type="text"
- :placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
- class="input-field w-full rounded-none"
- />
+ <button
+ v-for="fav in collapsedFavouritePreview"
+ :key="fav.destination_hash"
+ type="button"
+ class="shrink-0 p-1 rounded-xl transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
+ :class="
+ fav.destination_hash === selectedDestinationHash
+ ? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
+ : 'hover:bg-white/10'
+ "
+ :title="fav.display_name"
+ @click="onFavouriteClick(fav)"
+ >
+ <MaterialDesignIcon icon-name="server-network" class="size-6 text-gray-600 dark:text-gray-300" />
+ </button>
</div>
<div
- class="flex items-center justify-between px-3 pt-2 text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"
+ v-else-if="tab === 'announces'"
+ class="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden flex flex-col items-center gap-1 py-1 px-0.5"
>
- <span class="font-semibold">Sections</span>
<button
+ v-for="node in collapsedAnnounceNodesPreview"
+ :key="node.destination_hash"
type="button"
- class="inline-flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
- @click="createSection"
+ class="shrink-0 p-1 rounded-xl transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
+ :class="
+ node.destination_hash === selectedDestinationHash
+ ? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
+ : 'hover:bg-white/10'
+ "
+ :title="node.display_name"
+ @click="onNodeClick(node)"
>
- <MaterialDesignIcon icon-name="plus" class="size-4" />
- <span>Add Section</span>
+ <MaterialDesignIcon icon-name="satellite-uplink" class="size-6 text-gray-600 dark:text-gray-300" />
</button>
</div>
- <div class="flex-1 overflow-y-auto px-2 pb-4">
- <div v-if="favourites.length === 0" class="empty-state">
- <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
- <div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
- <div class="text-sm text-gray-500 dark:text-gray-400">
- {{ $t("nomadnet.add_nodes_from_announces") }}
- </div>
+ </div>
+ <template v-else>
+ <div
+ class="flex h-12 min-w-0 items-stretch border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
+ >
+ <div class="flex min-w-0 flex-1">
+ <button
+ type="button"
+ class="sidebar-tab"
+ :class="{ 'sidebar-tab--active': tab === 'favourites' }"
+ @click="tab = 'favourites'"
+ >
+ {{ $t("nomadnet.favourites") }}
+ </button>
+ <button
+ type="button"
+ class="sidebar-tab"
+ :class="{ 'sidebar-tab--active': tab === 'announces' }"
+ @click="tab = 'announces'"
+ >
+ {{ $t("nomadnet.announces") }}
+ </button>
</div>
- <div v-else-if="hasFavouriteResults" class="space-y-3 pt-2">
- <div
- v-for="section in sectionsWithFavourites"
- :key="section.id"
- class="rounded-xl"
- :class="[
- dragOverSectionId === section.id
- ? 'ring-1 ring-blue-400 dark:ring-blue-600 bg-blue-50/40 dark:bg-blue-900/10'
- : '',
- draggingSectionOverId === section.id
- ? 'ring-1 ring-blue-300 dark:ring-blue-700 bg-blue-50/30 dark:bg-blue-900/5'
- : '',
- ]"
- @dragover.prevent="onSectionDragOver(section.id)"
- @dragleave="onSectionDragLeave"
- @drop.prevent="onDropOnSection(section.id)"
+ <button
+ type="button"
+ class="hidden sm:flex shrink-0 items-center border-b-2 border-transparent px-1.5 text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
+ @click="$emit('toggle-collapse')"
+ >
+ <MaterialDesignIcon icon-name="chevron-left" class="size-5" />
+ </button>
+ </div>
+
+ <div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
+ <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
+ <input
+ v-model="favouritesSearchTerm"
+ type="text"
+ :placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
+ class="input-field w-full rounded-none"
+ />
+ </div>
+ <div
+ class="flex items-center justify-between px-3 pt-2 text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"
+ >
+ <span class="font-semibold">Sections</span>
+ <button
+ type="button"
+ class="inline-flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
+ @click="createSection"
>
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ <span>Add Section</span>
+ </button>
+ </div>
+ <div class="flex-1 overflow-y-auto px-2 pb-4">
+ <div v-if="favourites.length === 0" class="empty-state">
+ <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
+ <div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
+ <div class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("nomadnet.add_nodes_from_announces") }}
+ </div>
+ </div>
+ <div v-else-if="hasFavouriteResults" class="space-y-3 pt-2">
<div
- class="flex items-center justify-between px-2 py-1 cursor-pointer select-none"
- draggable="true"
- @click="toggleSectionCollapse(section.id)"
- @contextmenu.prevent="openSectionContextMenu($event, section)"
- @dragstart="onSectionDragStart(section.id)"
- @dragover.prevent="onSectionReorderDragOver(section.id)"
- @drop.prevent="onSectionDrop(section.id)"
- @dragend="onSectionDragEnd"
+ v-for="section in sectionsWithFavourites"
+ :key="section.id"
+ class="rounded-xl"
+ :class="[
+ dragOverSectionId === section.id
+ ? 'ring-1 ring-blue-400 dark:ring-blue-600 bg-blue-50/40 dark:bg-blue-900/10'
+ : '',
+ draggingSectionOverId === section.id
+ ? 'ring-1 ring-blue-300 dark:ring-blue-700 bg-blue-50/30 dark:bg-blue-900/5'
+ : '',
+ ]"
+ @dragover.prevent="onSectionDragOver(section.id)"
+ @dragleave="onSectionDragLeave"
+ @drop.prevent="onDropOnSection(section.id)"
>
- <div class="flex items-center gap-2">
- <MaterialDesignIcon
- :icon-name="section.collapsed ? 'chevron-right' : 'chevron-down'"
- class="size-4 text-gray-400"
- />
- <span
- class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300"
- >
- {{ section.name }}
- </span>
- <span
- v-if="section.collapsed"
- class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full"
- >
- {{ section.favourites.length }}
- </span>
- </div>
- <div class="flex items-center gap-1" @click.stop>
- <button
- type="button"
- class="p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 rounded-lg transition"
- @click="openSectionContextMenu($event, section)"
- >
- <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
- </button>
- </div>
- </div>
- <div class="h-px bg-gray-200 dark:bg-zinc-800 mx-1"></div>
- <div v-if="!section.collapsed" class="space-y-2 pt-2 pb-1 px-1">
<div
- v-for="favourite of section.favourites"
- :key="favourite.destination_hash"
- class="favourite-card relative"
- :class="[
- favourite.destination_hash === selectedDestinationHash
- ? 'favourite-card--active'
- : '',
- draggingFavouriteHash === favourite.destination_hash
- ? 'favourite-card--dragging'
- : '',
- ]"
+ class="flex items-center justify-between px-2 py-1 cursor-pointer select-none"
draggable="true"
- @click="onFavouriteClick(favourite)"
- @contextmenu.prevent="openFavouriteContextMenu($event, favourite, section.id)"
- @dragstart="onFavouriteDragStart($event, favourite, section.id)"
- @dragover.prevent="onFavouriteDragOver($event)"
- @drop.prevent="onFavouriteDrop($event, section.id, favourite)"
- @dragend="onFavouriteDragEnd"
+ @click="toggleSectionCollapse(section.id)"
+ @contextmenu.prevent="openSectionContextMenu($event, section)"
+ @dragstart="onSectionDragStart(section.id)"
+ @dragover.prevent="onSectionReorderDragOver(section.id)"
+ @drop.prevent="onSectionDrop(section.id)"
+ @dragend="onSectionDragEnd"
>
- <div
- v-if="
- GlobalState.config.banished_effect_enabled &&
- isBlocked(favourite.destination_hash)
- "
- class="banished-overlay"
- :style="{ background: GlobalState.config.banished_color + '33' }"
- >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon
+ :icon-name="section.collapsed ? 'chevron-right' : 'chevron-down'"
+ class="size-4 text-gray-400"
+ />
+ <span
+ class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300"
+ >
+ {{ section.name }}
+ </span>
<span
- class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
- :style="{ 'background-color': GlobalState.config.banished_color }"
- >{{ GlobalState.config.banished_text }}</span
+ v-if="section.collapsed"
+ class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full"
>
+ {{ section.favourites.length }}
+ </span>
</div>
-
- <div class="favourite-card__icon flex-shrink-0">
- <MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
+ <div class="flex items-center gap-1" @click.stop>
+ <button
+ type="button"
+ class="p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 rounded-lg transition"
+ @click="openSectionContextMenu($event, section)"
+ >
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
+ </button>
</div>
- <div class="min-w-0 flex-1">
+ </div>
+ <div class="h-px bg-gray-200 dark:bg-zinc-800 mx-1"></div>
+ <div v-if="!section.collapsed" class="space-y-2 pt-2 pb-1 px-1">
+ <div
+ v-for="favourite of section.favourites"
+ :key="favourite.destination_hash"
+ class="favourite-card relative"
+ :class="[
+ favourite.destination_hash === selectedDestinationHash
+ ? 'favourite-card--active'
+ : '',
+ draggingFavouriteHash === favourite.destination_hash
+ ? 'favourite-card--dragging'
+ : '',
+ ]"
+ draggable="true"
+ @click="onFavouriteClick(favourite)"
+ @contextmenu.prevent="openFavouriteContextMenu($event, favourite, section.id)"
+ @dragstart="onFavouriteDragStart($event, favourite, section.id)"
+ @dragover.prevent="onFavouriteDragOver($event)"
+ @drop.prevent="onFavouriteDrop($event, section.id, favourite)"
+ @dragend="onFavouriteDragEnd"
+ >
<div
- class="text-sm font-semibold text-gray-900 dark:text-white truncate"
- :title="favourite.display_name"
+ v-if="
+ GlobalState.config.banished_effect_enabled &&
+ isBlocked(favourite.destination_hash)
+ "
+ class="banished-overlay"
+ :style="{ background: GlobalState.config.banished_color + '33' }"
>
- {{ favourite.display_name }}
+ <span
+ class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
+ :style="{ 'background-color': GlobalState.config.banished_color }"
+ >{{ GlobalState.config.banished_text }}</span
+ >
</div>
- <div
- class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer inline-flex items-center"
- :title="$t('common.copy_to_clipboard')"
- @click.stop="copyToClipboard(favourite.destination_hash, 'Address')"
- >
- {{ formatDestinationHash(favourite.destination_hash) }}
+
+ <div class="favourite-card__icon flex-shrink-0">
+ <MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
+ </div>
+ <div class="min-w-0 flex-1">
+ <div
+ class="text-sm font-semibold text-gray-900 dark:text-white truncate"
+ :title="favourite.display_name"
+ >
+ {{ favourite.display_name }}
+ </div>
+ <div
+ class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer inline-flex items-center"
+ :title="$t('common.copy_to_clipboard')"
+ @click.stop="copyToClipboard(favourite.destination_hash, 'Address')"
+ >
+ {{ formatDestinationHash(favourite.destination_hash) }}
+ </div>
</div>
+ <IconButton
+ class="flex-shrink-0 text-gray-500 dark:text-gray-300"
+ @click.stop="openFavouriteContextMenu($event, favourite, section.id)"
+ >
+ <MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
+ </IconButton>
</div>
- <IconButton
- class="flex-shrink-0 text-gray-500 dark:text-gray-300"
- @click.stop="openFavouriteContextMenu($event, favourite, section.id)"
+ <div
+ v-if="section.favourites.length === 0"
+ class="text-xs text-gray-500 dark:text-gray-400 px-3 pb-2 italic"
>
- <MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
- </IconButton>
- </div>
- <div
- v-if="section.favourites.length === 0"
- class="text-xs text-gray-500 dark:text-gray-400 px-3 pb-2 italic"
- >
- No favourites in this section.
+ No favourites in this section.
+ </div>
</div>
</div>
</div>
+ <div v-else class="empty-state">
+ <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
+ <div class="font-semibold">No favourites match your search</div>
+ <div class="text-sm text-gray-500 dark:text-gray-400">Try a different search term.</div>
+ </div>
</div>
- <div v-else class="empty-state">
- <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
- <div class="font-semibold">No favourites match your search</div>
- <div class="text-sm text-gray-500 dark:text-gray-400">Try a different search term.</div>
- </div>
- </div>
- <!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
- <Teleport to="body">
- <div
- v-if="favouriteContextMenu.show"
- v-click-outside="{
- handler: () => {
- if (!favouriteContextMenu.justOpened) closeContextMenus();
- },
- capture: true,
- }"
- class="fixed z-[200] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
- >
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="renameFavouriteFromContext"
- >
- <MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
- <span class="font-medium">{{ $t("nomadnet.rename") }}</span>
- </button>
- <button
- v-if="!isBlocked(favouriteContextMenu.targetHash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="banishFavouriteFromContext"
- >
- <MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
- </button>
- <button
- v-else
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
- @click="unblockFavouriteFromContext"
- >
- <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
- <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="removeFavouriteFromContext"
- >
- <MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.remove") }}</span>
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
+ <Teleport to="body">
<div
- class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ v-if="favouriteContextMenu.show"
+ v-click-outside="{
+ handler: () => {
+ if (!favouriteContextMenu.justOpened) closeContextMenus();
+ },
+ capture: true,
+ }"
+ class="fixed z-[200] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
+ :style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
>
- Move to Section
- </div>
- <div class="max-h-56 overflow-y-auto custom-scrollbar">
<button
- v-for="section in sectionsWithFavourites"
- :key="section.id + '-move'"
type="button"
- class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
- @click="moveContextFavouriteToSection(section.id)"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="renameFavouriteFromContext"
+ >
+ <MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
+ <span class="font-medium">{{ $t("nomadnet.rename") }}</span>
+ </button>
+ <button
+ v-if="!isBlocked(favouriteContextMenu.targetHash)"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ @click="banishFavouriteFromContext"
+ >
+ <MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
+ <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
+ </button>
+ <button
+ v-else
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
+ @click="unblockFavouriteFromContext"
+ >
+ <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
+ <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
+ </button>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ @click="removeFavouriteFromContext"
>
- <MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
- <span class="truncate">{{ section.name }}</span>
+ <MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
+ <span class="font-medium">{{ $t("nomadnet.remove") }}</span>
</button>
+ <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <div
+ class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ >
+ Move to Section
+ </div>
+ <div class="max-h-56 overflow-y-auto custom-scrollbar">
+ <button
+ v-for="section in sectionsWithFavourites"
+ :key="section.id + '-move'"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
+ @click="moveContextFavouriteToSection(section.id)"
+ >
+ <MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
+ <span class="truncate">{{ section.name }}</span>
+ </button>
+ </div>
</div>
- </div>
- </Teleport>
-
- <!-- Section Context Menu (Teleport to body) -->
- <Teleport to="body">
- <div
- v-if="sectionContextMenu.show"
- v-click-outside="{ handler: closeContextMenus, capture: true }"
- class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
- >
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="renameSectionFromContext"
- >
- <MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
- <span class="font-medium">Rename Section</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- :disabled="sectionContextMenu.sectionId === defaultSectionId"
- :class="
- sectionContextMenu.sectionId === defaultSectionId ? 'opacity-50 cursor-not-allowed' : ''
- "
- @click="removeSectionFromContext"
- >
- <MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
- <span class="font-medium">Delete Section</span>
- </button>
- </div>
- </Teleport>
- </div>
+ </Teleport>
- <div v-else class="flex-1 flex flex-col min-h-0">
- <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
- <input
- :value="nodesSearchTerm"
- type="text"
- :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
- class="input-field w-full rounded-none"
- @input="onNodesSearchInput"
- />
- </div>
- <div class="flex-1 overflow-y-auto px-2 pb-4" @scroll="onNodesScroll">
- <div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
+ <!-- Section Context Menu (Teleport to body) -->
+ <Teleport to="body">
<div
- v-for="node of searchedNodes"
- :key="node.destination_hash"
- class="announce-card relative"
- :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
- @contextmenu.prevent="openAnnounceContextMenu($event, node)"
+ v-if="sectionContextMenu.show"
+ v-click-outside="{ handler: closeContextMenus, capture: true }"
+ class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
+ :style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
>
- <!-- banished overlay -->
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="renameSectionFromContext"
+ >
+ <MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
+ <span class="font-medium">Rename Section</span>
+ </button>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ :disabled="sectionContextMenu.sectionId === defaultSectionId"
+ :class="
+ sectionContextMenu.sectionId === defaultSectionId ? 'opacity-50 cursor-not-allowed' : ''
+ "
+ @click="removeSectionFromContext"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
+ <span class="font-medium">Delete Section</span>
+ </button>
+ </div>
+ </Teleport>
+ </div>
+
+ <div v-else class="flex-1 flex flex-col min-h-0">
+ <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
+ <input
+ :value="nodesSearchTerm"
+ type="text"
+ :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
+ class="input-field w-full rounded-none"
+ @input="onNodesSearchInput"
+ />
+ </div>
+ <div class="flex-1 overflow-y-auto px-2 pb-4" @scroll="onNodesScroll">
+ <div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
<div
- v-if="GlobalState.config.banished_effect_enabled && isBlocked(node.identity_hash)"
- class="banished-overlay"
- :style="{ background: GlobalState.config.banished_color + '33' }"
+ v-for="node of searchedNodes"
+ :key="node.destination_hash"
+ class="announce-card relative"
+ :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
+ @contextmenu.prevent="openAnnounceContextMenu($event, node)"
>
- <span
- class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
- :style="{ 'background-color': GlobalState.config.banished_color }"
- >{{ GlobalState.config.banished_text }}</span
+ <!-- banished overlay -->
+ <div
+ v-if="GlobalState.config.banished_effect_enabled && isBlocked(node.identity_hash)"
+ class="banished-overlay"
+ :style="{ background: GlobalState.config.banished_color + '33' }"
>
- </div>
-
- <div class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer" @click="onNodeClick(node)">
- <div class="announce-card__icon flex-shrink-0">
- <MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5" />
- </div>
- <div class="min-w-0 flex-1">
- <div
- class="text-sm font-semibold text-gray-900 dark:text-white truncate"
- :title="node.display_name"
+ <span
+ class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
+ :style="{ 'background-color': GlobalState.config.banished_color }"
+ >{{ GlobalState.config.banished_text }}</span
>
- {{ node.display_name }}
+ </div>
+
+ <div
+ class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
+ @click="onNodeClick(node)"
+ >
+ <div class="announce-card__icon flex-shrink-0">
+ <MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5" />
</div>
- <div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
- <span class="truncate">{{
- $t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
- }}</span>
- <span
- class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
- :title="$t('common.copy_to_clipboard')"
- @click.stop="copyToClipboard(node.destination_hash, 'Address')"
+ <div class="min-w-0 flex-1">
+ <div
+ class="text-sm font-semibold text-gray-900 dark:text-white truncate"
+ :title="node.display_name"
>
- {{ formatDestinationHash(node.destination_hash) }}
- </span>
+ {{ node.display_name }}
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
+ <span class="truncate">{{
+ $t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
+ }}</span>
+ <span
+ class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
+ :title="$t('common.copy_to_clipboard')"
+ @click.stop="copyToClipboard(node.destination_hash, 'Address')"
+ >
+ {{ formatDestinationHash(node.destination_hash) }}
+ </span>
+ </div>
</div>
</div>
+ <div class="flex-shrink-0">
+ <DropDownMenu>
+ <template #button>
+ <IconButton>
+ <MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
+ </IconButton>
+ </template>
+ <template #items>
+ <DropDownMenuItem
+ v-if="!isBlocked(node.identity_hash)"
+ @click.stop="onBlockNode(node)"
+ >
+ <MaterialDesignIcon icon-name="gavel" class="w-5 h-5 text-red-500" />
+ <span class="text-red-500">{{ $t("nomadnet.block_node") }}</span>
+ </DropDownMenuItem>
+ <DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
+ <MaterialDesignIcon
+ icon-name="check-circle"
+ class="w-5 h-5 text-green-500"
+ />
+ <span class="text-green-500">{{ $t("nomadnet.lift_banishment") }}</span>
+ </DropDownMenuItem>
+ </template>
+ </DropDownMenu>
+ </div>
</div>
- <div class="flex-shrink-0">
- <DropDownMenu>
- <template #button>
- <IconButton>
- <MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
- </IconButton>
- </template>
- <template #items>
- <DropDownMenuItem
- v-if="!isBlocked(node.identity_hash)"
- @click.stop="onBlockNode(node)"
- >
- <MaterialDesignIcon icon-name="gavel" class="w-5 h-5 text-red-500" />
- <span class="text-red-500">{{ $t("nomadnet.block_node") }}</span>
- </DropDownMenuItem>
- <DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
- <MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
- <span class="text-green-500">{{ $t("nomadnet.lift_banishment") }}</span>
- </DropDownMenuItem>
- </template>
- </DropDownMenu>
+
+ <!-- loading more spinner -->
+ <div v-if="isLoadingMoreNodes" class="p-4 text-center">
+ <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
</div>
-
- <!-- loading more spinner -->
- <div v-if="isLoadingMoreNodes" class="p-4 text-center">
- <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
+ <div v-else class="empty-state">
+ <MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
+ <div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
+ <div class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("nomadnet.listening_for_peers") }}
+ </div>
</div>
</div>
- <div v-else class="empty-state">
- <MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
- <div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
- <div class="text-sm text-gray-500 dark:text-gray-400">{{ $t("nomadnet.listening_for_peers") }}</div>
- </div>
- </div>
- <!-- Announce Context Menu (right-click, Teleport to body) -->
- <Teleport to="body">
- <div
- v-if="announceContextMenu.show"
- v-click-outside="{
- handler: () => {
- if (!announceContextMenu.justOpened) closeContextMenus();
- },
- capture: true,
- }"
- class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: announceContextMenu.y + 'px', left: announceContextMenu.x + 'px' }"
- >
- <button
- v-if="!isFavourite(announceContextMenu.node?.destination_hash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="addFavouriteFromContext"
- >
- <MaterialDesignIcon icon-name="star-outline" class="size-4 text-yellow-500" />
- <span class="font-medium">{{ $t("nomadnet.add_favourite") }}</span>
- </button>
- <button
- v-if="!isBlocked(announceContextMenu.node?.identity_hash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="blockAnnounceFromContext"
- >
- <MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
- </button>
- <button
- v-else
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
- @click="unblockAnnounceFromContext"
+ <!-- Announce Context Menu (right-click, Teleport to body) -->
+ <Teleport to="body">
+ <div
+ v-if="announceContextMenu.show"
+ v-click-outside="{
+ handler: () => {
+ if (!announceContextMenu.justOpened) closeContextMenus();
+ },
+ capture: true,
+ }"
+ class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
+ :style="{ top: announceContextMenu.y + 'px', left: announceContextMenu.x + 'px' }"
>
- <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
- <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
- </button>
- </div>
- </Teleport>
- </div>
+ <button
+ v-if="!isFavourite(announceContextMenu.node?.destination_hash)"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="addFavouriteFromContext"
+ >
+ <MaterialDesignIcon icon-name="star-outline" class="size-4 text-yellow-500" />
+ <span class="font-medium">{{ $t("nomadnet.add_favourite") }}</span>
+ </button>
+ <button
+ v-if="!isBlocked(announceContextMenu.node?.identity_hash)"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ @click="blockAnnounceFromContext"
+ >
+ <MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
+ <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
+ </button>
+ <button
+ v-else
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
+ @click="unblockAnnounceFromContext"
+ >
+ <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
+ <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
+ </button>
+ </div>
+ </Teleport>
+ </div>
+ </template>
</div>
</template>
@@ -465,6 +566,10 @@ export default {
type: Boolean,
default: false,
},
+ collapsed: {
+ type: Boolean,
+ default: false,
+ },
},
emits: [
"node-click",
@@ -473,6 +578,7 @@ export default {
"add-favourite",
"nodes-search-changed",
"load-more-nodes",
+ "toggle-collapse",
],
data() {
return {
@@ -509,9 +615,19 @@ export default {
node: null,
justOpened: false,
},
+ smUp: typeof window !== "undefined" ? window.innerWidth >= 640 : true,
};
},
computed: {
+ effectiveCollapsed() {
+ return this.collapsed && this.smUp;
+ },
+ sidebarRootClass() {
+ if (this.effectiveCollapsed) {
+ return "flex flex-col w-16 min-w-16 max-w-16 h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800";
+ }
+ return "flex flex-col w-full sm:w-80 sm:min-w-80 md:max-lg:w-64 md:max-lg:min-w-64 lg:w-80 lg:min-w-80 min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800";
+ },
blockedDestinations() {
return GlobalState.blockedDestinations;
},
@@ -563,6 +679,27 @@ export default {
}
return this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
},
+ collapsedFavouritePreview() {
+ const out = [];
+ const max = 5;
+ for (const section of this.orderedSections) {
+ const hashes = this.favouritesBySection[section.id] || [];
+ for (const hash of hashes) {
+ const fav = this.favourites.find((f) => f.destination_hash === hash);
+ if (!fav) {
+ continue;
+ }
+ if (out.length >= max) {
+ return out;
+ }
+ out.push(fav);
+ }
+ }
+ return out;
+ },
+ collapsedAnnounceNodesPreview() {
+ return this.nodesOrderedByLatestAnnounce.slice(0, 5);
+ },
},
watch: {
favourites: {
@@ -575,6 +712,17 @@ export default {
mounted() {
this.loadFavouriteLayout();
this.ensureFavouriteLayout();
+ this._smUpMql = window.matchMedia("(min-width: 640px)");
+ this._smUpResize = () => {
+ this.smUp = this._smUpMql.matches;
+ };
+ this._smUpResize();
+ this._smUpMql.addEventListener("change", this._smUpResize);
+ },
+ unmounted() {
+ if (this._smUpMql && this._smUpResize) {
+ this._smUpMql.removeEventListener("change", this._smUpResize);
+ }
},
methods: {
matchesFavouriteSearch(favourite, searchTerm = this.favouritesSearchTerm.toLowerCase()) {
@@ -1071,7 +1219,7 @@ export default {
<style scoped>
.sidebar-tab {
- @apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
+ @apply flex h-full w-1/2 items-center justify-center text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
}
.sidebar-tab--active {
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;

diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
index a7a79658..e573e951 100644
--- a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
+++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
@@ -2,10 +2,10 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-5xl mx-auto">
<div class="glass-card space-y-5">
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reticulum Content Serving

diff --git a/meshchatx/src/frontend/components/ping/PingPage.vue b/meshchatx/src/frontend/components/ping/PingPage.vue
index 379c597c..0df68d72 100644
--- a/meshchatx/src/frontend/components/ping/PingPage.vue
+++ b/meshchatx/src/frontend/components/ping/PingPage.vue
@@ -3,7 +3,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">

diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index 7e92bd32..6589cbe9 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
@@ -83,7 +83,7 @@
</div>
<div v-if="activeTab === 'send'" class="space-y-4">
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.destination_hash") }}</label>
<input
@@ -120,7 +120,7 @@
</div>
</div>
</div>
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.timeout_seconds") }}</label>
<input v-model="sendTimeout" type="number" min="1" class="input-field" />
@@ -194,7 +194,7 @@
</div>
<div v-if="activeTab === 'fetch'" class="space-y-4">
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.destination_hash") }}</label>
<input
@@ -214,7 +214,7 @@
/>
</div>
</div>
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.save_path_optional") }}</label>
<div class="flex gap-2">
@@ -320,7 +320,7 @@
class="input-field font-mono text-sm"
></textarea>
</div>
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.fetch_jail_path") }}</label>
<input

diff --git a/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue b/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
index 4e3b1839..e78baef9 100644
--- a/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
+++ b/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
@@ -17,7 +17,7 @@
</div>
</div>
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rnprobe.destination_hash") }}</label>
<input
@@ -38,7 +38,7 @@
</div>
</div>
- <div class="grid md:grid-cols-3 gap-4">
+ <div class="grid lg:grid-cols-3 gap-4">
<div>
<label class="glass-label">{{ $t("rnprobe.probe_size_bytes") }}</label>
<input v-model="probeSize" type="number" min="1" max="1024" class="input-field" />

diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
index 99da2674..3ed41bdf 100644
--- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
+++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<div class="glass-card space-y-5 rounded-2xl border border-slate-200/70 p-5 dark:border-zinc-700/80">
<div class="space-y-2">
@@ -48,7 +48,7 @@
</div>
</div>
- <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
+ <div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div
v-if="linkCount !== null"
class="rounded-xl border border-blue-200/80 bg-blue-50/90 p-4 text-blue-800 dark:border-blue-800/50 dark:bg-blue-950/30 dark:text-blue-200"

diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
index 1ae8b9db..8d8d4901 100644
--- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
+++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-6 w-full max-w-4xl mx-auto">
<!-- header -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index f5b81be4..ee65c7bd 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3,10 +3,10 @@
v-if="config"
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-8 py-4 sm:py-6">
+ <div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
<div class="settings-section settings-section--hero">
- <div class="flex flex-col md:flex-row md:items-center gap-4">
+ <div class="flex flex-col lg:flex-row lg:items-center gap-4">
<div class="flex-1 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("app.profile") }}
@@ -274,6 +274,61 @@
</div>
</section>
+ <section
+ v-show="matchesSearch(...sectionKeywords.stickers)"
+ class="settings-section break-inside-avoid"
+ >
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Messages</div>
+ <h2>{{ $t("stickers.settings_title") }}</h2>
+ <p>{{ $t("stickers.settings_description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <div class="text-sm text-gray-600 dark:text-gray-400">
+ {{ $t("stickers.count", { count: stickerCount }) }}
+ </div>
+ <label
+ class="flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200 cursor-pointer"
+ >
+ <input v-model="stickerImportReplaceDuplicates" type="checkbox" class="rounded" />
+ {{ $t("stickers.replace_duplicates") }}
+ </label>
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-amber-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-amber-500 transition group"
+ @click="exportStickers"
+ >
+ <MaterialDesignIcon
+ icon-name="export"
+ class="size-6 text-amber-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">{{ $t("stickers.export") }}</div>
+ </button>
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-teal-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-teal-500 transition group"
+ @click="triggerStickerImport"
+ >
+ <MaterialDesignIcon
+ icon-name="import"
+ class="size-6 text-teal-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">{{ $t("stickers.import") }}</div>
+ </button>
+ <input
+ ref="stickerImportFile"
+ type="file"
+ accept=".json,application/json"
+ class="hidden"
+ @change="importStickers"
+ />
+ </div>
+ </div>
+ </section>
+
<!-- Maintenance & Data -->
<section
v-show="matchesSearch(...sectionKeywords.maintenance)"
@@ -352,6 +407,22 @@
</div>
</button>
+ <button
+ type="button"
+ class="btn-maintenance border-amber-200 dark:border-amber-900/30 text-amber-800 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/10 hover:bg-amber-100 dark:hover:bg-amber-900/20"
+ @click="clearStickers"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="emoticon-outline" class="size-4" />
+ {{ $t("maintenance.clear_stickers") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_stickers_desc") }}
+ </div>
+ </div>
+ </button>
+
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@@ -838,6 +909,26 @@
/>
</div>
</div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ Waiting Color
+ </div>
+ <div class="flex gap-2">
+ <input
+ v-model="config.message_waiting_bubble_color"
+ type="color"
+ class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
+ @input="onMessageBubbleColorChange('waiting')"
+ />
+ <input
+ v-model="config.message_waiting_bubble_color"
+ type="text"
+ class="input-field monospace-field flex-1"
+ @input="onMessageBubbleColorChange('waiting')"
+ />
+ </div>
+ </div>
</div>
<div class="space-y-2">
@@ -1812,7 +1903,7 @@
</div>
</div>
<div class="settings-section__body">
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div
v-for="shortcut in KeyboardShortcuts.getDefaultShortcuts()"
:key="shortcut.action"
@@ -1902,6 +1993,7 @@ export default {
message_outbound_bubble_color: "#4f46e5",
message_inbound_bubble_color: null,
message_failed_bubble_color: "#ef4444",
+ message_waiting_bubble_color: "#e5e7eb",
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
location_source: "browser",
@@ -1923,6 +2015,8 @@ export default {
reloadingRns: false,
searchQuery: "",
trustedTelemetryPeers: [],
+ stickerCount: 0,
+ stickerImportReplaceDuplicates: false,
sectionKeywords: {
banishment: [
"Visuals",
@@ -1935,6 +2029,14 @@ export default {
"app.banished_color_label",
"app.banished_color_description",
],
+ stickers: [
+ "Stickers",
+ "stickers.settings_title",
+ "stickers.settings_description",
+ "stickers.export",
+ "stickers.import",
+ "stickers.replace_duplicates",
+ ],
maintenance: [
"Maintenance",
"maintenance.title",
@@ -1945,8 +2047,14 @@ export default {
"maintenance.clear_announces_desc",
"maintenance.clear_nomadnet_favs",
"maintenance.clear_nomadnet_favs_desc",
+ "maintenance.clear_lxmf_icons",
+ "maintenance.clear_lxmf_icons_desc",
+ "maintenance.clear_stickers",
+ "maintenance.clear_stickers_desc",
"maintenance.clear_archives",
"maintenance.clear_archives_desc",
+ "maintenance.clear_reticulum_docs",
+ "maintenance.clear_reticulum_docs_desc",
"maintenance.export_messages",
"maintenance.export_messages_desc",
"maintenance.import_messages",
@@ -1986,6 +2094,8 @@ export default {
"app.dark_theme",
"Message Font Size",
"Icon Size",
+ "Message Bubbles",
+ "Waiting Color",
"app.live_preview",
"app.realtime",
],
@@ -2125,6 +2235,7 @@ export default {
this.getConfig();
this.getTrustedTelemetryPeers();
+ this.loadStickerCount();
},
methods: {
async getTrustedTelemetryPeers() {
@@ -2247,6 +2358,7 @@ export default {
this.config.banished_color = hex6(this.config.banished_color, "#dc2626");
this.config.message_outbound_bubble_color = hex6(this.config.message_outbound_bubble_color, "#4f46e5");
this.config.message_failed_bubble_color = hex6(this.config.message_failed_bubble_color, "#ef4444");
+ this.config.message_waiting_bubble_color = hex6(this.config.message_waiting_bubble_color, "#e5e7eb");
const inbound = this.config.message_inbound_bubble_color;
if (inbound == null || inbound === "") {
this.config.message_inbound_bubble_color = null;
@@ -2753,6 +2865,70 @@ export default {
ToastUtils.error(this.$t("common.error"));
}
},
+ async clearStickers() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.api.delete("/api/v1/maintenance/stickers");
+ ToastUtils.success(this.$t("maintenance.stickers_cleared"));
+ await this.loadStickerCount();
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ async loadStickerCount() {
+ try {
+ const response = await window.api.get("/api/v1/stickers");
+ const list = response.data?.stickers;
+ this.stickerCount = Array.isArray(list) ? list.length : 0;
+ } catch {
+ this.stickerCount = 0;
+ }
+ },
+ async exportStickers() {
+ try {
+ const response = await window.api.get("/api/v1/stickers/export");
+ const dataStr = JSON.stringify(response.data, null, 2);
+ const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
+ const exportFileDefaultName = `meshchat_stickers_${new Date().toISOString().slice(0, 10)}.json`;
+ const linkElement = document.createElement("a");
+ linkElement.setAttribute("href", dataUri);
+ linkElement.setAttribute("download", exportFileDefaultName);
+ linkElement.click();
+ ToastUtils.success(this.$t("stickers.export_done"));
+ } catch {
+ ToastUtils.error(this.$t("stickers.import_failed"));
+ }
+ },
+ triggerStickerImport() {
+ this.$refs.stickerImportFile.click();
+ },
+ async importStickers(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const data = JSON.parse(e.target.result);
+ const response = await window.api.post("/api/v1/stickers/import", {
+ ...data,
+ replace_duplicates: this.stickerImportReplaceDuplicates,
+ });
+ const r = response.data;
+ ToastUtils.success(
+ this.$t("stickers.import_success", {
+ imported: r.imported ?? 0,
+ skipped_duplicates: r.skipped_duplicates ?? 0,
+ skipped_invalid: r.skipped_invalid ?? 0,
+ })
+ );
+ await this.loadStickerCount();
+ } catch {
+ ToastUtils.error(this.$t("stickers.import_failed"));
+ }
+ };
+ reader.readAsText(file);
+ event.target.value = "";
+ },
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {

diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue
index 9636d060..de1e013a 100644
--- a/meshchatx/src/frontend/components/tools/BotsPage.vue
+++ b/meshchatx/src/frontend/components/tools/BotsPage.vue
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-white dark:bg-zinc-950">
- <div class="flex-1 overflow-y-auto w-full px-3 sm:px-4 md:px-8 py-4 sm:py-6">
+ <div class="flex-1 overflow-y-auto w-full px-3 sm:px-4 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="space-y-8 w-full max-w-4xl mx-auto">
<div class="space-y-2 border-b border-gray-200 dark:border-zinc-800 pb-6">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">

diff --git a/meshchatx/src/frontend/components/translator/TranslatorPage.vue b/meshchatx/src/frontend/components/translator/TranslatorPage.vue
index 55b9fc11..fa1f943c 100644
--- a/meshchatx/src/frontend/components/translator/TranslatorPage.vue
+++ b/meshchatx/src/frontend/components/translator/TranslatorPage.vue
@@ -2,7 +2,7 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
@@ -84,7 +84,7 @@
</div>
</div>
- <div class="grid md:grid-cols-2 gap-4">
+ <div class="grid lg:grid-cols-2 gap-4">
<div>
<label class="glass-label">Source Language</label>
<select v-model="sourceLang" class="input-field">


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────